Skip to content

Commit 879479c

Browse files
authored
Merge pull request #8578 from kenjis/improve-redis-session
feat: improve Redis Session
2 parents 8c91b0d + 615aa18 commit 879479c

File tree

6 files changed

+207
-82
lines changed

6 files changed

+207
-82
lines changed

app/Config/Session.php

+25
Original file line numberDiff line numberDiff line change
@@ -99,4 +99,29 @@ class Session extends BaseConfig
9999
* DB Group for the database session.
100100
*/
101101
public ?string $DBGroup = null;
102+
103+
/**
104+
* --------------------------------------------------------------------------
105+
* Lock Retry Interval (microseconds)
106+
* --------------------------------------------------------------------------
107+
*
108+
* This is used for RedisHandler.
109+
*
110+
* Time (microseconds) to wait if lock cannot be acquired.
111+
* The default is 100,000 microseconds (= 0.1 seconds).
112+
*/
113+
public int $lockRetryInterval = 100_000;
114+
115+
/**
116+
* --------------------------------------------------------------------------
117+
* Lock Max Retries
118+
* --------------------------------------------------------------------------
119+
*
120+
* This is used for RedisHandler.
121+
*
122+
* Maximum number of lock acquisition attempts.
123+
* The default is 300 times. That is lock timeout is about 30 (0.1 * 300)
124+
* seconds.
125+
*/
126+
public int $lockMaxRetries = 300;
102127
}

phpstan-baseline.php

+1-6
Original file line numberDiff line numberDiff line change
@@ -8333,12 +8333,7 @@
83338333
];
83348334
$ignoreErrors[] = [
83358335
'message' => '#^Construct empty\\(\\) is not allowed\\. Use more strict comparison\\.$#',
8336-
'count' => 4,
8337-
'path' => __DIR__ . '/system/Session/Handlers/RedisHandler.php',
8338-
];
8339-
$ignoreErrors[] = [
8340-
'message' => '#^Only booleans are allowed in &&, string given on the right side\\.$#',
8341-
'count' => 1,
8336+
'count' => 2,
83428337
'path' => __DIR__ . '/system/Session/Handlers/RedisHandler.php',
83438338
];
83448339
$ignoreErrors[] = [

system/Session/Handlers/RedisHandler.php

+66-18
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,16 @@ class RedisHandler extends BaseHandler
6363
*/
6464
protected $sessionExpiration = 7200;
6565

66+
/**
67+
* Time (microseconds) to wait if lock cannot be acquired.
68+
*/
69+
private int $lockRetryInterval = 100_000;
70+
71+
/**
72+
* Maximum number of lock acquisition attempts.
73+
*/
74+
private int $lockMaxRetries = 300;
75+
6676
/**
6777
* @param string $ipAddress User's IP address
6878
*
@@ -76,6 +86,7 @@ public function __construct(SessionConfig $config, string $ipAddress)
7686
$this->sessionExpiration = ($config->expiration === 0)
7787
? (int) ini_get('session.gc_maxlifetime')
7888
: $config->expiration;
89+
7990
// Add sessionCookieName for multiple session cookies.
8091
$this->keyPrefix .= $config->cookieName . ':';
8192

@@ -84,6 +95,9 @@ public function __construct(SessionConfig $config, string $ipAddress)
8495
if ($this->matchIP === true) {
8596
$this->keyPrefix .= $this->ipAddress . ':';
8697
}
98+
99+
$this->lockRetryInterval = $config->lockWait ?? $this->lockRetryInterval;
100+
$this->lockMaxRetries = $config->lockAttempts ?? $this->lockMaxRetries;
87101
}
88102

89103
protected function setSavePath(): void
@@ -92,23 +106,57 @@ protected function setSavePath(): void
92106
throw SessionException::forEmptySavepath();
93107
}
94108

95-
if (preg_match('#(?:(tcp|tls)://)?([^:?]+)(?:\:(\d+))?(\?.+)?#', $this->savePath, $matches)) {
96-
if (! isset($matches[4])) {
97-
$matches[4] = ''; // Just to avoid undefined index notices below
98-
}
109+
$url = parse_url($this->savePath);
110+
$query = [];
99111

100-
$this->savePath = [
101-
'protocol' => ! empty($matches[1]) ? $matches[1] : self::DEFAULT_PROTOCOL,
102-
'host' => $matches[2],
103-
'port' => empty($matches[3]) ? self::DEFAULT_PORT : $matches[3],
104-
'password' => preg_match('#auth=([^\s&]+)#', $matches[4], $match) ? $match[1] : null,
105-
'database' => preg_match('#database=(\d+)#', $matches[4], $match) ? (int) $match[1] : 0,
106-
'timeout' => preg_match('#timeout=(\d+\.\d+|\d+)#', $matches[4], $match) ? (float) $match[1] : 0.0,
107-
];
112+
if ($url === false) {
113+
// Unix domain socket like `unix:///var/run/redis/redis.sock?persistent=1`.
114+
if (preg_match('#unix://(/[^:?]+)(\?.+)?#', $this->savePath, $matches)) {
115+
$host = $matches[1];
116+
$port = 0;
108117

109-
preg_match('#prefix=([^\s&]+)#', $matches[4], $match) && $this->keyPrefix = $match[1];
118+
if (isset($matches[2])) {
119+
parse_str(ltrim($matches[2], '?'), $query);
120+
}
121+
} else {
122+
throw SessionException::forInvalidSavePathFormat($this->savePath);
123+
}
110124
} else {
111-
throw SessionException::forInvalidSavePathFormat($this->savePath);
125+
// Also accepts `/var/run/redis.sock` for backward compatibility.
126+
if (isset($url['path']) && $url['path'][0] === '/') {
127+
$host = $url['path'];
128+
$port = 0;
129+
} else {
130+
// TCP connection.
131+
if (! isset($url['host'])) {
132+
throw SessionException::forInvalidSavePathFormat($this->savePath);
133+
}
134+
135+
$protocol = $url['scheme'] ?? self::DEFAULT_PROTOCOL;
136+
$host = $protocol . '://' . $url['host'];
137+
$port = $url['port'] ?? self::DEFAULT_PORT;
138+
}
139+
140+
if (isset($url['query'])) {
141+
parse_str($url['query'], $query);
142+
}
143+
}
144+
145+
$password = $query['auth'] ?? null;
146+
$database = isset($query['database']) ? (int) $query['database'] : 0;
147+
$timeout = isset($query['timeout']) ? (float) $query['timeout'] : 0.0;
148+
$prefix = $query['prefix'] ?? null;
149+
150+
$this->savePath = [
151+
'host' => $host,
152+
'port' => $port,
153+
'password' => $password,
154+
'database' => $database,
155+
'timeout' => $timeout,
156+
];
157+
158+
if ($prefix !== null) {
159+
$this->keyPrefix = $prefix;
112160
}
113161
}
114162

@@ -130,8 +178,8 @@ public function open($path, $name): bool
130178

131179
if (
132180
! $redis->connect(
133-
$this->savePath['protocol'] . '://' . $this->savePath['host'],
134-
($this->savePath['host'][0] === '/') ? 0 : (int) $this->savePath['port'],
181+
$this->savePath['host'],
182+
$this->savePath['port'],
135183
$this->savePath['timeout']
136184
)
137185
) {
@@ -325,14 +373,14 @@ protected function lockSession(string $sessionID): bool
325373
);
326374

327375
if (! $result) {
328-
usleep(100000);
376+
usleep($this->lockRetryInterval);
329377

330378
continue;
331379
}
332380

333381
$this->lockKey = $lockKey;
334382
break;
335-
} while (++$attempt < 300);
383+
} while (++$attempt < $this->lockMaxRetries);
336384

337385
if ($attempt === 300) {
338386
$this->logger->error(

tests/system/Session/Handlers/Database/RedisHandlerTest.php

+99-57
Original file line numberDiff line numberDiff line change
@@ -52,63 +52,6 @@ protected function getInstance($options = [])
5252
return new RedisHandler($sessionConfig, $this->userIpAddress);
5353
}
5454

55-
public function testSavePathWithoutProtocol(): void
56-
{
57-
$handler = $this->getInstance(
58-
['savePath' => '127.0.0.1:6379']
59-
);
60-
61-
$savePath = $this->getPrivateProperty($handler, 'savePath');
62-
63-
$this->assertSame('tcp', $savePath['protocol']);
64-
}
65-
66-
public function testSavePathTLSAuth(): void
67-
{
68-
$handler = $this->getInstance(
69-
['savePath' => 'tls://127.0.0.1:6379?auth=password']
70-
);
71-
72-
$savePath = $this->getPrivateProperty($handler, 'savePath');
73-
74-
$this->assertSame('tls', $savePath['protocol']);
75-
$this->assertSame('password', $savePath['password']);
76-
}
77-
78-
public function testSavePathTCPAuth(): void
79-
{
80-
$handler = $this->getInstance(
81-
['savePath' => 'tcp://127.0.0.1:6379?auth=password']
82-
);
83-
84-
$savePath = $this->getPrivateProperty($handler, 'savePath');
85-
86-
$this->assertSame('tcp', $savePath['protocol']);
87-
$this->assertSame('password', $savePath['password']);
88-
}
89-
90-
public function testSavePathTimeoutFloat(): void
91-
{
92-
$handler = $this->getInstance(
93-
['savePath' => 'tcp://127.0.0.1:6379?timeout=2.5']
94-
);
95-
96-
$savePath = $this->getPrivateProperty($handler, 'savePath');
97-
98-
$this->assertEqualsWithDelta(2.5, $savePath['timeout'], PHP_FLOAT_EPSILON);
99-
}
100-
101-
public function testSavePathTimeoutInt(): void
102-
{
103-
$handler = $this->getInstance(
104-
['savePath' => 'tcp://127.0.0.1:6379?timeout=10']
105-
);
106-
107-
$savePath = $this->getPrivateProperty($handler, 'savePath');
108-
109-
$this->assertEqualsWithDelta(10.0, $savePath['timeout'], PHP_FLOAT_EPSILON);
110-
}
111-
11255
public function testOpen(): void
11356
{
11457
$handler = $this->getInstance();
@@ -192,4 +135,103 @@ public function testSecondaryReadAfterClose(): void
192135

193136
$handler->close();
194137
}
138+
139+
/**
140+
* @dataProvider provideSetSavePath
141+
*/
142+
public function testSetSavePath(string $savePath, array $expected): void
143+
{
144+
$option = ['savePath' => $savePath];
145+
$handler = $this->getInstance($option);
146+
147+
$savePath = $this->getPrivateProperty($handler, 'savePath');
148+
149+
$this->assertSame($expected, $savePath);
150+
}
151+
152+
public static function provideSetSavePath(): iterable
153+
{
154+
yield from [
155+
'w/o protocol' => [
156+
'127.0.0.1:6379',
157+
[
158+
'host' => 'tcp://127.0.0.1',
159+
'port' => 6379,
160+
'password' => null,
161+
'database' => 0,
162+
'timeout' => 0.0,
163+
],
164+
],
165+
'tls auth' => [
166+
'tls://127.0.0.1:6379?auth=password',
167+
[
168+
'host' => 'tls://127.0.0.1',
169+
'port' => 6379,
170+
'password' => 'password',
171+
'database' => 0,
172+
'timeout' => 0.0,
173+
],
174+
],
175+
'tcp auth' => [
176+
'tcp://127.0.0.1:6379?auth=password',
177+
[
178+
'host' => 'tcp://127.0.0.1',
179+
'port' => 6379,
180+
'password' => 'password',
181+
'database' => 0,
182+
'timeout' => 0.0,
183+
],
184+
],
185+
'timeout float' => [
186+
'tcp://127.0.0.1:6379?timeout=2.5',
187+
[
188+
'host' => 'tcp://127.0.0.1',
189+
'port' => 6379,
190+
'password' => null,
191+
'database' => 0,
192+
'timeout' => 2.5,
193+
],
194+
],
195+
'timeout int' => [
196+
'tcp://127.0.0.1:6379?timeout=10',
197+
[
198+
'host' => 'tcp://127.0.0.1',
199+
'port' => 6379,
200+
'password' => null,
201+
'database' => 0,
202+
'timeout' => 10.0,
203+
],
204+
],
205+
'auth acl' => [
206+
'tcp://localhost:6379?auth[user]=redis-admin&auth[pass]=admin-password',
207+
[
208+
'host' => 'tcp://localhost',
209+
'port' => 6379,
210+
'password' => ['user' => 'redis-admin', 'pass' => 'admin-password'],
211+
'database' => 0,
212+
'timeout' => 0.0,
213+
],
214+
],
215+
'unix domain socket' => [
216+
'unix:///tmp/redis.sock',
217+
[
218+
'host' => '/tmp/redis.sock',
219+
'port' => 0,
220+
'password' => null,
221+
'database' => 0,
222+
'timeout' => 0.0,
223+
],
224+
],
225+
'unix domain socket w/o protocol' => [
226+
'/tmp/redis.sock',
227+
[
228+
'host' => '/tmp/redis.sock',
229+
'port' => 0,
230+
'password' => null,
231+
'database' => 0,
232+
'timeout' => 0.0,
233+
],
234+
],
235+
];
236+
}
195237
}

user_guide_src/source/changelogs/v4.5.0.rst

+5
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,11 @@ Libraries
118118
- The ``$dbGroup`` parameter of ``Validation::run()`` now accepts not only
119119
a database group name, but also a database connection instance or an array
120120
of database settings.
121+
- **Session:**
122+
- ``RedisHandler`` now can configure the interval time for acquiring locks
123+
(``$lockRetryInterval``) and the number of retries (``$lockMaxRetries``).
124+
- Now you can use Redis ACL (username and password) with ``RedisHandler``.
125+
See :ref:`sessions-redishandler-driver` for details.
121126

122127
Helpers and Functions
123128
=====================

user_guide_src/source/libraries/sessions.rst

+11-1
Original file line numberDiff line numberDiff line change
@@ -679,7 +679,9 @@ RedisHandler Driver
679679

680680
.. note:: Since Redis doesn't have a locking mechanism exposed, locks for
681681
this driver are emulated by a separate value that is kept for up
682-
to 300 seconds. With ``v4.3.2`` or above, you can connect ``Redis`` with **TLS** protocol.
682+
to 300 seconds.
683+
684+
.. note:: Starting with v4.3.2, you can connect Redis with **TLS** protocol.
683685

684686
Redis is a storage engine typically used for caching and popular because
685687
of its high performance, which is also probably your reason to use the
@@ -713,6 +715,14 @@ sufficient:
713715

714716
.. literalinclude:: sessions/041.php
715717

718+
Starting with v4.5.0, you can use Redis ACL (username and password)::
719+
720+
public string $savePath = 'tcp://localhost:6379?auth[user]=username&auth[pass]=password';
721+
722+
.. note:: Starting with v4.5.0, the interval time for acquiring locks
723+
(``$lockRetryInterval``) and the number of retries (``$lockMaxRetries``) are
724+
configurable.
725+
716726
.. _sessions-memcachedhandler-driver:
717727

718728
MemcachedHandler Driver

0 commit comments

Comments
 (0)