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

codeigniter4 / CodeIgniter4 / 30759273559

02 Aug 2026 05:37PM UTC coverage: 90.044% (-0.2%) from 90.2%
30759273559

Pull #10436

github

web-flow
Merge fed5b6064 into 1c093bd73
Pull Request #10436: feat: add Redis Sentinel support to cache and session Redis handlers

42 of 95 new or added lines in 6 files covered. (44.21%)

25641 of 28476 relevant lines covered (90.04%)

232.3 hits per line

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

71.51
/system/Session/Handlers/RedisHandler.php
1
<?php
2

3
declare(strict_types=1);
4

5
/**
6
 * This file is part of CodeIgniter 4 framework.
7
 *
8
 * (c) CodeIgniter Foundation <admin@codeigniter.com>
9
 *
10
 * For the full copyright and license information, please view
11
 * the LICENSE file that was distributed with this source code.
12
 */
13

14
namespace CodeIgniter\Session\Handlers;
15

16
use CodeIgniter\Cache\Handlers\RedisSentinel;
17
use CodeIgniter\I18n\Time;
18
use CodeIgniter\Session\Exceptions\SessionException;
19
use CodeIgniter\Session\PersistsConnection;
20
use Config\Session as SessionConfig;
21
use Redis;
22
use RedisException;
23
use RuntimeException;
24

25
/**
26
 * Session handler using Redis for persistence.
27
 */
28
class RedisHandler extends BaseHandler
29
{
30
    use PersistsConnection;
31

32
    private const DEFAULT_PORT     = 6379;
33
    private const DEFAULT_PROTOCOL = 'tcp';
34

35
    /**
36
     * Sentinel configuration, when set takes precedence over $savePath.
37
     *
38
     * @var array{
39
     *   service?: string,
40
     *   nodes?: list<array{host: string, port?: int}>,
41
     *   timeout?: float,
42
     *   persistent?: bool,
43
     *   password?: string|null,
44
     *   database?: int
45
     * }
46
     */
47
    protected array $sentinel = [];
48

49
    /**
50
     * phpRedis instance.
51
     *
52
     * @var Redis|null
53
     */
54
    protected $redis;
55

56
    /**
57
     * Key prefix.
58
     *
59
     * @var string
60
     */
61
    protected $keyPrefix = 'ci_session:';
62

63
    /**
64
     * Lock key.
65
     *
66
     * @var string|null
67
     */
68
    protected $lockKey;
69

70
    /**
71
     * Key exists flag.
72
     *
73
     * @var bool
74
     */
75
    protected $keyExists = false;
76

77
    /**
78
     * Number of seconds until the session ends.
79
     *
80
     * @var int
81
     */
82
    protected $sessionExpiration = 7200;
83

84
    /**
85
     * Time (microseconds) to wait if lock cannot be acquired.
86
     */
87
    private int $lockRetryInterval = 100_000;
88

89
    /**
90
     * Maximum number of lock acquisition attempts.
91
     */
92
    private int $lockMaxRetries = 300;
93

94
    /**
95
     * @param string $ipAddress User's IP address.
96
     *
97
     * @throws SessionException
98
     */
99
    public function __construct(SessionConfig $config, string $ipAddress)
100
    {
101
        parent::__construct($config, $ipAddress);
24✔
102

103
        // Store Session configurations.
104
        $this->sessionExpiration = ($config->expiration === 0)
24✔
105
            ? (int) ini_get('session.gc_maxlifetime')
×
106
            : $config->expiration;
24✔
107

108
        // Add session cookie name for multiple session cookies.
109
        $this->keyPrefix .= $config->cookieName . ':';
24✔
110

111
        // Store Sentinel configuration; when populated it overrides $savePath.
112
        $this->sentinel = $config->sentinel;
24✔
113

114
        $this->setSavePath();
24✔
115

116
        if ($this->matchIP === true) {
24✔
117
            $this->keyPrefix .= $this->ipAddress . ':';
×
118
        }
119

120
        $this->lockRetryInterval = $config->lockRetryInterval ?? $this->lockRetryInterval; // @phpstan-ignore nullCoalesce.property
24✔
121
        $this->lockMaxRetries    = $config->lockMaxRetries ?? $this->lockMaxRetries; // @phpstan-ignore nullCoalesce.property
24✔
122
    }
123

124
    protected function setSavePath(): void
125
    {
126
        // When a Sentinel cluster is configured, build a Sentinel-shaped save
127
        // path and skip the single-host savePath parsing. This also means an
128
        // empty $savePath is valid as long as $sentinel is populated.
129
        if (($this->sentinel['nodes'] ?? []) !== []) {
24✔
130
            $this->savePath = [
1✔
131
                'sentinel'   => true,
1✔
132
                'service'    => $this->sentinel['service'] ?? '',
1✔
133
                'nodes'      => $this->sentinel['nodes'],
1✔
134
                'password'   => $this->sentinel['password'] ?? null,
1✔
135
                'database'   => $this->sentinel['database'] ?? 0,
1✔
136
                'timeout'    => (float) ($this->sentinel['timeout'] ?? 0.0),
1✔
137
                'persistent' => isset($this->sentinel['persistent'])
1✔
138
                    ? filter_var($this->sentinel['persistent'], FILTER_VALIDATE_BOOL)
1✔
139
                    : null,
140
            ];
1✔
141

142
            return;
1✔
143
        }
144

145
        if ($this->savePath === '') {
23✔
146
            throw SessionException::forEmptySavepath();
×
147
        }
148

149
        $url   = parse_url($this->savePath);
23✔
150
        $query = [];
23✔
151

152
        if ($url === false) {
23✔
153
            // Unix domain socket like `unix:///var/run/redis/redis.sock?persistent=1`.
154
            if (preg_match('#unix://(/[^:?]+)(\?.+)?#', $this->savePath, $matches)) {
1✔
155
                $host = $matches[1];
1✔
156
                $port = 0;
1✔
157

158
                if (isset($matches[2])) {
1✔
159
                    parse_str(ltrim($matches[2], '?'), $query);
×
160
                }
161
            } else {
162
                throw SessionException::forInvalidSavePathFormat($this->savePath);
×
163
            }
164
        } else {
165
            // Also accepts `/var/run/redis.sock` for backward compatibility.
166
            if (isset($url['path']) && $url['path'][0] === '/') {
22✔
167
                $host = $url['path'];
1✔
168
                $port = 0;
1✔
169
            } else {
170
                // TCP connection.
171
                if (! isset($url['host'])) {
21✔
172
                    throw SessionException::forInvalidSavePathFormat($this->savePath);
×
173
                }
174

175
                $protocol = $url['scheme'] ?? self::DEFAULT_PROTOCOL;
21✔
176
                $host     = $protocol . '://' . $url['host'];
21✔
177
                $port     = $url['port'] ?? self::DEFAULT_PORT;
21✔
178
            }
179

180
            if (isset($url['query'])) {
22✔
181
                parse_str($url['query'], $query);
9✔
182
            }
183
        }
184

185
        $persistent = isset($query['persistent']) ? filter_var($query['persistent'], FILTER_VALIDATE_BOOL) : null;
23✔
186
        $password   = $query['auth'] ?? null;
23✔
187
        $database   = isset($query['database']) ? (int) $query['database'] : 0;
23✔
188
        $timeout    = isset($query['timeout']) ? (float) $query['timeout'] : 0.0;
23✔
189
        $prefix     = $query['prefix'] ?? null;
23✔
190

191
        $this->savePath = [
23✔
192
            'host'       => $host,
23✔
193
            'port'       => $port,
23✔
194
            'password'   => $password,
23✔
195
            'database'   => $database,
23✔
196
            'timeout'    => $timeout,
23✔
197
            'persistent' => $persistent,
23✔
198
        ];
23✔
199

200
        if ($prefix !== null) {
23✔
201
            $this->keyPrefix = $prefix;
×
202
        }
203
    }
204

205
    /**
206
     * Re-initialize existing session, or creates a new one.
207
     *
208
     * @param string $path The path where to store/retrieve the session.
209
     * @param string $name The session name.
210
     *
211
     * @throws RedisException
212
     */
213
    public function open($path, $name): bool
214
    {
215
        if (empty($this->savePath)) {
10✔
216
            return false;
×
217
        }
218

219
        if ($this->hasPersistentConnection()) {
10✔
220
            $redis = $this->getPersistentConnection();
3✔
221

222
            try {
223
                $pingReply = $redis->ping();
3✔
224

225
                if (in_array($pingReply, [true, '+PONG'], true)) {
3✔
226
                    $this->redis = $redis;
3✔
227

228
                    return true;
3✔
229
                }
230
            } catch (RedisException) {
×
231
                $this->setPersistentConnection(null);
×
232
            }
233
        }
234

235
        // When using Sentinel, discover the current master address first.
236
        if (($this->savePath['sentinel'] ?? false) === true) {
10✔
237
            try {
NEW
238
                [$host, $port] = RedisSentinel::discoverMaster(
×
NEW
239
                    $this->savePath['nodes'],
×
NEW
240
                    $this->savePath['service'],
×
NEW
241
                    $this->savePath['timeout'],
×
NEW
242
                );
×
NEW
243
            } catch (RuntimeException $e) {
×
NEW
244
                $this->logger->error(
×
NEW
245
                    'Session: Redis Sentinel unable to discover master "'
×
NEW
246
                    . $this->savePath['service'] . '": ' . $e->getMessage(),
×
NEW
247
                );
×
248

NEW
249
                return false;
×
250
            }
251
        } else {
252
            $host = $this->savePath['host'];
10✔
253
            $port = $this->savePath['port'];
10✔
254
        }
255

256
        $redis = new Redis();
10✔
257

258
        $funcConnection = isset($this->savePath['persistent']) && $this->savePath['persistent'] === true
10✔
259
            ? 'pconnect'
×
260
            : 'connect';
10✔
261

262
        if ($redis->{$funcConnection}($host, $port, $this->savePath['timeout']) === false) {
10✔
263
            $this->logger->error('Session: Unable to connect to Redis with the configured settings.');
×
264
        } elseif (isset($this->savePath['password']) && ! $redis->auth($this->savePath['password'])) {
10✔
265
            $this->logger->error('Session: Unable to authenticate to Redis instance.');
×
266
        } elseif (isset($this->savePath['database']) && ! $redis->select($this->savePath['database'])) {
10✔
267
            $this->logger->error(
×
268
                'Session: Unable to select Redis database with index ' . $this->savePath['database'],
×
269
            );
×
270
        } else {
271
            $this->setPersistentConnection($redis);
10✔
272
            $this->redis = $redis;
10✔
273

274
            return true;
10✔
275
        }
276

277
        return false;
×
278
    }
279

280
    /**
281
     * Reads the session data from the session storage, and returns the results.
282
     *
283
     * @param string $id The session ID.
284
     *
285
     * @throws RedisException
286
     */
287
    public function read($id): false|string
288
    {
289
        if (isset($this->redis) && $this->lockSession($id)) {
5✔
290
            if (! isset($this->sessionID)) {
5✔
291
                $this->sessionID = $id;
5✔
292
            }
293

294
            $data = $this->redis->get($this->keyPrefix . $id);
5✔
295

296
            if (is_string($data)) {
5✔
297
                $this->keyExists = true;
2✔
298
            } else {
299
                $data = '';
3✔
300
            }
301

302
            $this->fingerprint = md5($data);
5✔
303

304
            return $data;
5✔
305
        }
306

307
        return false;
1✔
308
    }
309

310
    /**
311
     * Writes the session data to the session storage.
312
     *
313
     * @param string $id   The session ID.
314
     * @param string $data The encoded session data.
315
     *
316
     * @throws RedisException
317
     */
318
    public function write($id, $data): bool
319
    {
320
        if (! isset($this->redis)) {
1✔
321
            return false;
×
322
        }
323

324
        if ($this->sessionID !== $id) {
1✔
325
            if (! $this->releaseLock() || ! $this->lockSession($id)) {
×
326
                return false;
×
327
            }
328

329
            $this->keyExists = false;
×
330
            $this->sessionID = $id;
×
331
        }
332

333
        if (isset($this->lockKey)) {
1✔
334
            $this->redis->expire($this->lockKey, 300);
1✔
335

336
            if ($this->fingerprint !== ($fingerprint = md5($data)) || $this->keyExists === false) {
1✔
337
                if ($this->redis->set($this->keyPrefix . $id, $data, $this->sessionExpiration)) {
1✔
338
                    $this->fingerprint = $fingerprint;
1✔
339
                    $this->keyExists   = true;
1✔
340

341
                    return true;
1✔
342
                }
343

344
                return false;
×
345
            }
346

347
            return $this->redis->expire($this->keyPrefix . $id, $this->sessionExpiration);
×
348
        }
349

350
        return false;
×
351
    }
352

353
    /**
354
     * Closes the current session.
355
     */
356
    public function close(): bool
357
    {
358
        if (isset($this->redis)) {
8✔
359
            try {
360
                $pingReply = $this->redis->ping();
8✔
361

362
                if (in_array($pingReply, [true, '+PONG'], true) && isset($this->lockKey) && ! $this->releaseLock()) {
8✔
363
                    return false;
8✔
364
                }
365
            } catch (RedisException $e) {
×
366
                $this->logger->error('Session: Got RedisException on close(): ' . $e->getMessage());
×
367
            }
368

369
            return true;
8✔
370
        }
371

372
        return true;
×
373
    }
374

375
    /**
376
     * Destroys a session.
377
     *
378
     * @param string $id The session ID being destroyed.
379
     *
380
     * @throws RedisException
381
     */
382
    public function destroy($id): bool
383
    {
384
        if (isset($this->redis, $this->lockKey)) {
×
385
            if (($result = $this->redis->del($this->keyPrefix . $id)) !== 1) {
×
386
                $this->logger->debug(
×
387
                    'Session: Redis::del() expected to return 1, got ' . var_export($result, true) . ' instead.',
×
388
                );
×
389
            }
390

391
            return $this->destroyCookie();
×
392
        }
393

394
        return false;
×
395
    }
396

397
    /**
398
     * Cleans up expired sessions.
399
     *
400
     * @param int $max_lifetime Sessions that have not updated
401
     *                          for the last max_lifetime seconds will be removed.
402
     */
403
    public function gc(int $max_lifetime): int
404
    {
405
        return 1;
1✔
406
    }
407

408
    /**
409
     * Acquires an emulated lock.
410
     *
411
     * @param string $sessionID Session ID.
412
     *
413
     * @throws RedisException
414
     */
415
    protected function lockSession(string $sessionID): bool
416
    {
417
        $lockKey = $this->keyPrefix . $sessionID . ':lock';
5✔
418

419
        // PHP 7 reuses the SessionHandler object on regeneration,
420
        // so we need to check here if the lock key is for the
421
        // correct session ID.
422
        if ($this->lockKey === $lockKey) {
5✔
423
            // If there is the lock, make the ttl longer.
424
            return $this->redis->expire($this->lockKey, 300);
×
425
        }
426

427
        $attempt = 0;
5✔
428

429
        do {
430
            $result = $this->redis->set(
5✔
431
                $lockKey,
5✔
432
                (string) Time::now()->getTimestamp(),
5✔
433
                // NX -- Only set the key if it does not already exist.
434
                // EX seconds -- Set the specified expire time, in seconds.
435
                ['nx', 'ex' => 300],
5✔
436
            );
5✔
437

438
            if (! $result) {
5✔
439
                usleep($this->lockRetryInterval);
1✔
440

441
                continue;
1✔
442
            }
443

444
            $this->lockKey = $lockKey;
5✔
445
            break;
5✔
446
        } while (++$attempt < $this->lockMaxRetries);
1✔
447

448
        if ($attempt === $this->lockMaxRetries) {
5✔
449
            $this->logger->error(
1✔
450
                'Session: Unable to obtain lock for ' . $this->keyPrefix . $sessionID
1✔
451
                . ' after ' . $this->lockMaxRetries . ' attempts, aborting.',
1✔
452
            );
1✔
453

454
            return false;
1✔
455
        }
456

457
        $this->lock = true;
5✔
458

459
        return true;
5✔
460
    }
461

462
    /**
463
     * Releases a previously acquired lock.
464
     *
465
     * @throws RedisException
466
     */
467
    protected function releaseLock(): bool
468
    {
469
        if (isset($this->redis, $this->lockKey) && $this->lock) {
5✔
470
            if (! $this->redis->del($this->lockKey)) {
5✔
471
                $this->logger->error('Session: Error while trying to free lock for ' . $this->lockKey);
×
472

473
                return false;
×
474
            }
475

476
            $this->lockKey = null;
5✔
477
            $this->lock    = false;
5✔
478
        }
479

480
        return true;
5✔
481
    }
482
}
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