diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/session.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/session.php index 30a622d02c8fb..70564beeb2b8b 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/session.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/session.php @@ -16,6 +16,8 @@ use Symfony\Component\HttpFoundation\Session\Storage\Handler\IdentityMarshaller; use Symfony\Component\HttpFoundation\Session\Storage\Handler\MarshallingSessionHandler; use Symfony\Component\HttpFoundation\Session\Storage\Handler\NativeFileSessionHandler; +use Symfony\Component\HttpFoundation\Session\Storage\Handler\NativeMemcachedSessionHandler; +use Symfony\Component\HttpFoundation\Session\Storage\Handler\NativeRedisSessionHandler; use Symfony\Component\HttpFoundation\Session\Storage\Handler\SessionHandlerFactory; use Symfony\Component\HttpFoundation\Session\Storage\Handler\StrictSessionHandler; use Symfony\Component\HttpFoundation\Session\Storage\MetadataBag; @@ -75,6 +77,18 @@ ->args([param('session.save_path')]), ]) + ->set('session.handler.native_memcached', StrictSessionHandler::class) + ->args([ + inline_service(NativeMemcachedSessionHandler::class) + ->args([param('session.save_path'), param('session.storage.options')]), + ]) + + ->set('session.handler.native_redis', StrictSessionHandler::class) + ->args([ + inline_service(NativeRedisSessionHandler::class) + ->args([param('session.save_path'), param('session.storage.options')]), + ]) + ->set('session.abstract_handler', AbstractSessionHandler::class) ->factory([SessionHandlerFactory::class, 'createHandler']) ->args([abstract_arg('A string or a connection object'), []]) diff --git a/src/Symfony/Component/HttpFoundation/CHANGELOG.md b/src/Symfony/Component/HttpFoundation/CHANGELOG.md index fdea3d67e1b19..f2b63db92c808 100644 --- a/src/Symfony/Component/HttpFoundation/CHANGELOG.md +++ b/src/Symfony/Component/HttpFoundation/CHANGELOG.md @@ -1,6 +1,11 @@ CHANGELOG ========= +6.3 +--- + + * Add `NativeMemcachedSessionHandler` and `NativeRedisSessionHandler` to `Symfony\Component\HttpFoundation\Session\Storage\Handler` for native (locking) sessions in Memcache and Redis. + 6.2 --- diff --git a/src/Symfony/Component/HttpFoundation/Session/Storage/Handler/NativeMemcachedSessionHandler.php b/src/Symfony/Component/HttpFoundation/Session/Storage/Handler/NativeMemcachedSessionHandler.php new file mode 100644 index 0000000000000..33a2408a7df9d --- /dev/null +++ b/src/Symfony/Component/HttpFoundation/Session/Storage/Handler/NativeMemcachedSessionHandler.php @@ -0,0 +1,88 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpFoundation\Session\Storage\Handler; + +/** + * Native session handler using php-memcached, PHP's Memcache extension (ext-memcached). + * + * @author Maurits van der Schee + */ +class NativeMemcachedSessionHandler extends \SessionHandler +{ + /** + * @param string $savePath tells php-memcached where to store the sessions + * + * @see https://github.com/php-memcached-dev/php-memcached for further details. + */ + public function __construct(string $savePath = null, array $sessionOptions = null) + { + // + // Sessions support (from: https://www.php.net/manual/en/memcached.sessions.php) + // + // Memcached provides a custom session handler that can be used to store user sessions in memcache. + // A completely separate memcached instance is used for that internally, so you can use a different + // server pool if necessary. The session keys are stored under the prefix memc.sess.key., so be aware + // of this if you use the same server pool for sessions and generic caching. + // + // - session.save_handler string + // Set to memcached to enable sessions support. + // + // - session.save_path string + // Defines a comma separated of hostname:port entries to use for session server + // pool, for example "sess1:11211, sess2:11211". + // + + $savePath ??= \ini_get('session.save_path'); + + ini_set('session.save_path', $savePath); + ini_set('session.save_handler', 'memcached'); + + // + // Runtime Configuration (from: https://www.php.net/manual/en/memcached.configuration.php) + // + // Here's a short explanation of the configuration directives. + // + // - memcached.sess_locking bool + // Use session locking. Valid values: On, Off, the default is On. + // + // - memcached.sess_prefix string + // Memcached session key prefix. Valid values are strings less than 219 bytes long. + // The default value is "memc.sess.key." + // + // - memcached.sess_lock_expire int + // The time, in seconds, before a lock should release itself. Setting to 0 results in the + // default behaviour, which is to use PHP's max_execution_time. Default is 0. + // + // - memcached.sess_lock_retries int + // The number of times to retry locking the session lock, not including the first attempt. + // Default is 5. + // + // - memcached.sess_lock_wait_min int + // The minimum time, in milliseconds, to wait between session lock attempts. This value is + // double on each lock retry until memcached.sess_lock_wait_max is reached, after which any + // further retries will take sess_lock_wait_max seconds. The default is 150. + // + + $sessionName = $sessionOptions['name'] ?? \ini_get('session.name'); + + $prefix = "memc.sess.key.$sessionName."; + $lock_expire = \ini_get('max_execution_time') ?: 30; // 30s + $lock_wait_time = \ini_get('memcached.sess_lock_wait_min') ?: 150; // 150ms + $lock_retries = (int) ($lock_expire / ($lock_wait_time / 1000)); // 200x + + ini_set('memcached.sess_locking', 1); + ini_set('memcached.sess_prefix', $prefix); + ini_set('memcached.sess_lock_expire', $lock_expire); + ini_set('memcached.sess_lock_wait_min', $lock_wait_time); + ini_set('memcached.sess_lock_retries', $lock_retries); + } +} diff --git a/src/Symfony/Component/HttpFoundation/Session/Storage/Handler/NativeRedisSessionHandler.php b/src/Symfony/Component/HttpFoundation/Session/Storage/Handler/NativeRedisSessionHandler.php new file mode 100644 index 0000000000000..677a86d4c9bae --- /dev/null +++ b/src/Symfony/Component/HttpFoundation/Session/Storage/Handler/NativeRedisSessionHandler.php @@ -0,0 +1,101 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpFoundation\Session\Storage\Handler; + +/** + * Native session handler using PhpRedis, PHP's Redis extension (ext-redis). + * + * @author Maurits van der Schee + */ +class NativeRedisSessionHandler extends \SessionHandler +{ + /** + * @param string $savePath tells PhpRedis where to store the sessions + * + * @see https://github.com/phpredis/phpredis#php-session-handler for further details. + */ + public function __construct(string $savePath = null, array $sessionOptions = null) + { + // + // PHP Session handler (from: https://github.com/phpredis/phpredis#php-session-handler) + // + // phpredis can be used to store PHP sessions. To do this, configure session.save_handler and session.save_path + // in your php.ini to tell phpredis where to store the sessions: + // + // session.save_handler = redis + // session.save_path = "tcp://host1:6379?weight=1, tcp://host2:6379?weight=2&timeout=2.5, tcp://host3:6379?weight=2&read_timeout=2.5" + // + // session.save_path can have a simple host:port format too, but you need to provide the tcp:// scheme if + // you want to use the parameters. The following parameters are available: + // + // - weight (integer): the weight of a host is used in comparison with the others in order to + // customize the session distribution on several hosts. If host A has twice the weight of host B, + // it will get twice the amount of sessions. In the example, host1 stores 20% of all the + // sessions (1/(1+2+2)) while host2 and host3 each store 40% (2/(1+2+2)). The target host is + // determined once and for all at the start of the session, and doesn't change. The default weight is 1. + // - timeout (float): the connection timeout to a redis host, expressed in seconds. + // If the host is unreachable in that amount of time, the session storage will be unavailable for the client. + // The default timeout is very high (86400 seconds). + // - persistent (integer, should be 1 or 0): defines if a persistent connection should be used. + // - prefix (string, defaults to "PHPREDIS_SESSION:"): used as a prefix to the Redis key in which the session is stored. + // The key is composed of the prefix followed by the session ID. + // - auth (string, or an array with one or two elements): used to authenticate with the server prior to sending commands. + // - database (integer): selects a different database. + // + // Sessions have a lifetime expressed in seconds and stored in the INI variable "session.gc_maxlifetime". + // You can change it with ini_set(). The session handler requires a version of Redis supporting EX and NX + // options of SET command (at least 2.6.12). phpredis can also connect to a unix domain socket: + // session.save_path = "unix:///var/run/redis/redis.sock?persistent=1&weight=1&database=0" + // + + $savePath ??= \ini_get('session.save_path'); + $sessionName = $sessionOptions['name'] ?? \ini_get('session.name'); + + $savePathParts = explode('?', $savePath, 2); + parse_str($savePathParts[1] ?? '', $arguments); + if (!isset($arguments['prefix'])) { + $arguments['prefix'] = "PHPREDIS_SESSION.$sessionName."; + } + $savePathParts[1] = http_build_query($arguments); + + ini_set('session.save_path', implode('?', $savePathParts)); + ini_set('session.save_handler', 'redis'); + + // + // Session locking (from: https://github.com/phpredis/phpredis#session-locking) + // + // Support: Locking feature is currently only supported for Redis setup with single master instance + // (e.g. classic master/slave Sentinel environment). + // So locking may not work properly in RedisArray or RedisCluster environments. + // + // Following INI variables can be used to configure session locking: + // + // ; Should the locking be enabled? Defaults to: 0. + // redis.session.locking_enabled = 1 + // ; How long should the lock live (in seconds)? Defaults to: value of max_execution_time (defaults to 30). + // redis.session.lock_expire = 60 + // ; How long to wait between attempts to acquire lock, in microseconds (µs)?. Defaults to: 20000 + // redis.session.lock_wait_time = 100000 + // ; Maximum number of times to retry (-1 means infinite). Defaults to: 100 + // redis.session.lock_retries = 300 + // + + $lock_expire = (\ini_get('redis.session.lock_expire') ?: \ini_get('max_execution_time')) ?: 30; // 30s + $lock_wait_time = (\ini_get('redis.session.lock_wait_time') ?: 20000); // 20ms + $lock_retries = (int) ($lock_expire / ($lock_wait_time / 1000000)); // 1500x + + ini_set('redis.session.locking_enabled', 1); + ini_set('redis.session.lock_expire', $lock_expire); + ini_set('redis.session.lock_wait_time', $lock_wait_time); + ini_set('redis.session.lock_retries', $lock_retries); + } +} diff --git a/src/Symfony/Component/HttpFoundation/Tests/Session/Storage/Handler/NativeMemcachedSessionHandlerTest.php b/src/Symfony/Component/HttpFoundation/Tests/Session/Storage/Handler/NativeMemcachedSessionHandlerTest.php new file mode 100644 index 0000000000000..5e66b5b07377d --- /dev/null +++ b/src/Symfony/Component/HttpFoundation/Tests/Session/Storage/Handler/NativeMemcachedSessionHandlerTest.php @@ -0,0 +1,64 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpFoundation\Tests\Session\Storage\Handler; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Session\Storage\Handler\NativeMemcachedSessionHandler; +use Symfony\Component\HttpFoundation\Session\Storage\NativeSessionStorage; + +/** + * Test class for NativeMemcachedSessionHandler. + * + * @author Maurits van der Schee + * + * @runTestsInSeparateProcesses + * + * @preserveGlobalState disabled + */ +class NativeMemcachedSessionHandlerTest extends TestCase +{ + /** + * @dataProvider savePathDataProvider + */ + public function testConstruct(string $savePath, array $sessionOptions, string $expectedSavePath, string $expectedSessionName) + { + ini_set('session.save_path', '/var/lib/php/sessions'); + ini_set('session.name', 'PHPSESSID'); + + new NativeSessionStorage($sessionOptions, new NativeMemcachedSessionHandler($savePath, $sessionOptions)); + + $this->assertEquals($expectedSessionName, \ini_get('session.name')); + $this->assertEquals($expectedSavePath, \ini_get('session.save_path')); + $this->assertTrue((bool) \ini_get('memcached.sess_locking')); + } + + public function savePathDataProvider() + { + return [ + ['localhost:11211', ['name' => 'TESTING'], 'localhost:11211', 'TESTING'], + ['', ['name' => 'TESTING'], '', 'TESTING'], + ['', [], '', 'PHPSESSID'], + ]; + } + + public function testConstructDefault() + { + ini_set('session.save_path', 'localhost:11211'); + ini_set('session.name', 'TESTING'); + + new NativeSessionStorage([], new NativeMemcachedSessionHandler()); + + $this->assertEquals('localhost:11211', \ini_get('session.save_path')); + $this->assertEquals('TESTING', \ini_get('session.name')); + $this->assertTrue((bool) \ini_get('memcached.sess_locking')); + } +} diff --git a/src/Symfony/Component/HttpFoundation/Tests/Session/Storage/Handler/NativeRedisSessionHandlerTest.php b/src/Symfony/Component/HttpFoundation/Tests/Session/Storage/Handler/NativeRedisSessionHandlerTest.php new file mode 100644 index 0000000000000..4ab09ab69ee2f --- /dev/null +++ b/src/Symfony/Component/HttpFoundation/Tests/Session/Storage/Handler/NativeRedisSessionHandlerTest.php @@ -0,0 +1,65 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpFoundation\Tests\Session\Storage\Handler; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Session\Storage\Handler\NativeRedisSessionHandler; +use Symfony\Component\HttpFoundation\Session\Storage\NativeSessionStorage; + +/** + * Test class for NativeRedisSessionHandler. + * + * @author Maurits van der Schee + * + * @runTestsInSeparateProcesses + * + * @preserveGlobalState disabled + */ +class NativeRedisSessionHandlerTest extends TestCase +{ + /** + * @dataProvider savePathDataProvider + */ + public function testConstruct(string $savePath, array $sessionOptions, string $expectedSavePath, string $expectedSessionName) + { + ini_set('session.save_path', '/var/lib/php/sessions'); + ini_set('session.name', 'PHPSESSID'); + + new NativeSessionStorage($sessionOptions, new NativeRedisSessionHandler($savePath, $sessionOptions)); + + $this->assertEquals($expectedSessionName, \ini_get('session.name')); + $this->assertEquals($expectedSavePath, \ini_get('session.save_path')); + $this->assertTrue((bool) \ini_get('redis.session.locking_enabled')); + } + + public function savePathDataProvider() + { + return [ + ['tcp://localhost:6379', [], 'tcp://localhost:6379?prefix=PHPREDIS_SESSION.PHPSESSID.', 'PHPSESSID'], + ['tcp://localhost:6379', ['name' => 'TESTING'], 'tcp://localhost:6379?prefix=PHPREDIS_SESSION.TESTING.', 'TESTING'], + ['tcp://localhost:6379?prefix=CUSTOM.', ['name' => 'TESTING'], 'tcp://localhost:6379?prefix=CUSTOM.', 'TESTING'], + ['tcp://localhost:6379?prefix=CUSTOM.&prefix=CUSTOM2.', ['name' => 'TESTING'], 'tcp://localhost:6379?prefix=CUSTOM2.', 'TESTING'], + ]; + } + + public function testConstructDefault() + { + ini_set('session.save_path', 'tcp://localhost:6379'); + ini_set('session.name', 'TESTING'); + + new NativeSessionStorage([], new NativeRedisSessionHandler()); + + $this->assertEquals('tcp://localhost:6379?prefix=PHPREDIS_SESSION.TESTING.', \ini_get('session.save_path')); + $this->assertEquals('TESTING', \ini_get('session.name')); + $this->assertTrue((bool) \ini_get('redis.session.locking_enabled')); + } +}