├── .gitignore
├── ISSUE_TEMPLATE.md
├── composer.json
├── .github
└── workflows
│ └── php.yml
├── LICENSE
├── src
└── Cm
│ └── RedisSession
│ ├── ConnectionFailedException.php
│ ├── ConcurrentConnectionsExceededException.php
│ ├── Handler
│ ├── ConfigSentinelPasswordInterface.php
│ ├── UsernameConfigInterface.php
│ ├── TlsOptionsConfigInterface.php
│ ├── ClusterConfigInterface.php
│ ├── LoggerInterface.php
│ └── ConfigInterface.php
│ └── Handler.php
├── phpunit.xml.dist
├── tests
└── Cm
│ └── RedisSession
│ └── HandlerTest.php
└── README.md
/.gitignore:
--------------------------------------------------------------------------------
1 | vendor/
2 | .idea/
3 | phpunit.xml
4 | composer.lock
5 |
--------------------------------------------------------------------------------
/ISSUE_TEMPLATE.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name":"colinmollenhour/php-redis-session-abstract",
3 | "type":"library",
4 | "license":"BSD-3-Clause",
5 | "homepage":"https://github.com/colinmollenhour/php-redis-session-abstract",
6 | "description":"A Redis-based session handler with optimistic locking",
7 | "scripts": {
8 | "test": "vendor/bin/phpunit tests"
9 | },
10 | "authors":[
11 | {
12 | "name":"Colin Mollenhour"
13 | }
14 | ],
15 | "require":{
16 | "php": "^7.4 || ^8.0",
17 | "colinmollenhour/credis":"~1.17"
18 | },
19 | "require-dev": {
20 | "phpunit/phpunit": "^9"
21 | },
22 | "autoload": {
23 | "psr-0": {
24 | "Cm\\RedisSession\\": "src/"
25 | }
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/.github/workflows/php.yml:
--------------------------------------------------------------------------------
1 | name: PHP Composer
2 |
3 | on:
4 | push:
5 | branches: [ "master" ]
6 | pull_request:
7 | branches: [ "master" ]
8 |
9 | permissions:
10 | contents: read
11 |
12 | jobs:
13 | build:
14 |
15 | runs-on: ubuntu-latest
16 | services:
17 | redis:
18 | image: redis
19 | options: >-
20 | --health-cmd "redis-cli ping"
21 | --health-interval 10s
22 | --health-timeout 5s
23 | --health-retries 5
24 |
25 | steps:
26 | - uses: actions/checkout@v4
27 |
28 | - name: Validate composer.json and composer.lock
29 | run: composer validate --strict
30 |
31 | - name: Cache Composer packages
32 | id: composer-cache
33 | uses: actions/cache@v3
34 | with:
35 | path: vendor
36 | key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }}
37 | restore-keys: |
38 | ${{ runner.os }}-php-
39 |
40 | - name: Install dependencies
41 | run: composer install --prefer-dist --no-progress
42 |
43 | - name: Run test suite
44 | run: composer run-script test
45 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright (c) 2013, Colin Mollenhour
2 |
3 | All rights reserved.
4 |
5 | Redistribution and use in source and binary forms, with or without
6 | modification, are permitted provided that the following conditions are met:
7 |
8 | * Redistributions of source code must retain the above copyright
9 | notice, this list of conditions and the following disclaimer.
10 |
11 | * Redistributions in binary form must reproduce the above copyright
12 | notice, this list of conditions and the following disclaimer in the
13 | documentation and/or other materials provided with the distribution.
14 |
15 | * The name of Colin Mollenhour may not be used to endorse or promote products
16 | derived from this software without specific prior written permission.
17 |
18 | * Redistributions in any form must not change the Cm_RedisSession namespace.
19 |
20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
21 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
22 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY
24 | DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
25 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
26 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
27 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
28 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
29 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
30 |
--------------------------------------------------------------------------------
/src/Cm/RedisSession/ConnectionFailedException.php:
--------------------------------------------------------------------------------
1 |
2 |
33 |
37 |
38 |
39 | tests
40 |
41 |
42 |
43 |
--------------------------------------------------------------------------------
/src/Cm/RedisSession/Handler/ClusterConfigInterface.php:
--------------------------------------------------------------------------------
1 | config = $this->createMock(ConfigInterface::class);
61 | $this->logger = $this->createMock(LoggerInterface::class);
62 | $this->handler = new Handler($this->config, $this->logger);
63 | }
64 |
65 | /**
66 | * Smoke test: open and close connection
67 | */
68 | public function testOpenClose()
69 | {
70 | $this->assertTrue($this->handler->open('', ''));
71 |
72 | $this->logger->expects($this->once())
73 | ->method('log')
74 | ->with($this->stringContains('Closing connection'), LoggerInterface::DEBUG);
75 |
76 | $this->assertTrue($this->handler->close());
77 | }
78 |
79 | /**
80 | * Test basic handler operations
81 | */
82 | public function testHandler()
83 | {
84 | $sessionId = 1;
85 | $data = 'data';
86 | $this->handler->destroy($sessionId);
87 | $this->assertTrue($this->handler->write($sessionId, $data));
88 | $this->assertEquals(0, $this->handler->getFailedLockAttempts());
89 | $this->assertEquals($data, $this->handler->read($sessionId));
90 | $this->handler->destroy($sessionId);
91 | $this->assertEquals('', $this->handler->read($sessionId));
92 | $this->assertTrue($this->handler->close());
93 | }
94 | }
95 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # php-redis-session-abstract #
2 |
3 | ### A Redis-based session handler with optimistic locking. ###
4 |
5 | #### Features: ####
6 | - When a session's data size exceeds the compression threshold the session data will be compressed.
7 | - Compression libraries supported are 'gzip', 'lzf', 'lz4', and 'snappy'.
8 | - Gzip is the slowest but offers the best compression ratios.
9 | - Lzf can be installed easily via PECL.
10 | - Lz4 is supported by HHVM.
11 | - Compression can be enabled, disabled, or reconfigured on the fly with no loss of session data.
12 | - Expiration is handled by Redis; no garbage collection needed.
13 | - Logs when sessions are not written due to not having or losing their lock.
14 | - Limits the number of concurrent lock requests.
15 | - Detects inactive waiting processes to prevent false-positives in concurrency throttling.
16 | - Detects crashed processes to prevent session deadlocks (Linux only).
17 | - Gives shorter session lifetimes to bots and crawlers to reduce wasted resources.
18 | - Locking can be disabled entirely
19 |
20 | #### Locking Algorithm Properties: ####
21 | - Only one process may get a write lock on a session.
22 | - A process may lose it's lock if another process breaks it, in which case the session will not be written.
23 | - The lock may be broken after `BREAK_AFTER` seconds and the process that gets the lock is indeterminate.
24 | - Only `MAX_CONCURRENCY` processes may be waiting for a lock for the same session or else a ConcurrentConnectionsExceededException will be thrown.
25 |
26 | ### Compression ##
27 |
28 | Session data compresses very well so using compression is a great way to increase your capacity without
29 | dedicating a ton of RAM to Redis and reducing network utilization.
30 | The default `compression threshold` is 2048 bytes so any session data equal to or larger than this size
31 | will be compressed with the chosen `compression_lib` which is `gzip` by default. Compression can be disabled by setting the `compression_lib` to `none`. However, both `lzf` and
32 | `snappy` offer much faster compression with comparable compression ratios so I definitely recommend using
33 | one of these if you have root. lzf is easy to install via pecl:
34 |
35 | sudo pecl install lzf
36 |
37 | _NOTE:_ If using suhosin with session data encryption enabled (default is `suhosin.session.encrypt=on`), two things:
38 |
39 | 1. You will probably get very poor compression ratios.
40 | 2. Lzf fails to compress the encrypted data in my experience. No idea why...
41 |
42 | If any compression lib fails to compress the session data an error will be logged in `system.log` and the
43 | session will still be saved without compression. If you have `suhosin.session.encrypt=on` I would either
44 | recommend disabling it (unless you are on a shared host since Magento does it's own session validation already)
45 | or disable compression or at least don't use lzf with encryption enabled.
46 |
47 | ## Bot Detection ##
48 |
49 | Bots and crawlers typically do not use cookies which means you may be storing thousands of sessions that
50 | serve no purpose. Even worse, an attacker could use your limited session storage against you by flooding
51 | your backend, thereby causing your legitimate sessions to get evicted. However, you don't want to misidentify
52 | a user as a bot and kill their session unintentionally. This module uses both a regex as well as a
53 | counter on the number of writes against the session to determine the session lifetime.
54 |
55 | ## Using with [Cm_Cache_Backend_Redis](https://github.com/colinmollenhour/Cm_Cache_Backend_Redis) ##
56 |
57 | Using Cm_RedisSession alongside Cm_Cache_Backend_Redis should be no problem at all. However, it is strongly advised
58 | to run two separate Redis instances even if they are running on the same server. Running two instances will
59 | actually perform better since Redis is single-threaded so on a multi-core server is bound by the performance of
60 | a single core. Also it makes sense to allocate varying amounts of memory to cache and sessions and to enforce different
61 | "maxmemory" policies. If you absolutely must run one Redis instance for both then just don't use the same 'db' number.
62 | But again, just run two Redis instances.
63 |
64 |
65 | ## License ##
66 |
67 | @copyright Copyright (c) 2013 Colin Mollenhour (http://colin.mollenhour.com)
68 | This project is licensed under the "New BSD" license (see source).
69 |
--------------------------------------------------------------------------------
/src/Cm/RedisSession/Handler/ConfigInterface.php:
--------------------------------------------------------------------------------
1 | config = $config;
285 | $this->logger = $logger;
286 |
287 | $this->logger->setLogLevel($this->config->getLogLevel() ?: self::DEFAULT_LOG_LEVEL);
288 | $timeStart = microtime(true);
289 |
290 | // Database config
291 | $host = $this->config->getHost() ?: self::DEFAULT_HOST;
292 | $port = $this->config->getPort() ?: self::DEFAULT_PORT;
293 | $pass = $this->config->getPassword() ?: null;
294 | $username = $this->config instanceof UsernameConfigInterface ? $this->config->getUsername() : null;
295 | $timeout = $this->config->getTimeout() ?: self::DEFAULT_TIMEOUT;
296 | $retries = $this->config->getRetries() ?: self::DEFAULT_RETRIES;
297 | $persistent = $this->config->getPersistentIdentifier() ?: '';
298 | $this->_dbNum = $this->config->getDatabase() ?: self::DEFAULT_DATABASE;
299 | $tlsOptions = $this->config instanceof TlsOptionsConfigInterface ? $this->config->getTlsOptions() : null;
300 |
301 | // General config
302 | $this->_readOnly = $readOnly;
303 | $this->_compressionThreshold = $this->config->getCompressionThreshold() ?: self::DEFAULT_COMPRESSION_THRESHOLD;
304 | $this->_compressionLibrary = $this->config->getCompressionLibrary() ?: self::DEFAULT_COMPRESSION_LIBRARY;
305 | $this->_maxConcurrency = $this->config->getMaxConcurrency() ?: self::DEFAULT_MAX_CONCURRENCY;
306 | $this->_failAfter = $this->config->getFailAfter() ?: self::DEFAULT_FAIL_AFTER;
307 | $this->_maxLifetime = $this->config->getMaxLifetime() ?: self::DEFAULT_MAX_LIFETIME;
308 | $this->_minLifetime = $this->config->getMinLifetime() ?: self::DEFAULT_MIN_LIFETIME;
309 | $this->_useLocking = ! $this->config->getDisableLocking();
310 |
311 | // Use sleep time multiplier so fail after time is in seconds
312 | $this->_failAfter = (int) round((1000000 / self::SLEEP_TIME) * $this->_failAfter);
313 |
314 | // Sentinel config
315 | $sentinelServers = $this->config->getSentinelServers();
316 | $sentinelMaster = $this->config->getSentinelMaster();
317 | $sentinelVerifyMaster = $this->config->getSentinelVerifyMaster();
318 | $sentinelConnectRetries = $this->config->getSentinelConnectRetries();
319 | $sentinelPassword = $this->config instanceof ConfigSentinelPasswordInterface
320 | ? $this->config->getSentinelPassword()
321 | : $pass;
322 |
323 | // Connect and authenticate
324 | if ($sentinelServers && $sentinelMaster) {
325 | $this->_usePipeline = true;
326 | $this->_useCluster = false;
327 | $servers = preg_split('/\s*,\s*/', trim($sentinelServers), -1, PREG_SPLIT_NO_EMPTY);
328 | $sentinel = NULL;
329 | $exception = NULL;
330 | for ($i = 0; $i <= $sentinelConnectRetries; $i++) // Try to connect to sentinels in round-robin fashion
331 | foreach ($servers as $server) {
332 | try {
333 | $sentinelClient = new \Credis_Client($server, NULL, $timeout, $persistent);
334 | $sentinelClient->forceStandalone();
335 | $sentinelClient->setMaxConnectRetries(0);
336 | if ($sentinelPassword) {
337 | try {
338 | $sentinelClient->auth($sentinelPassword);
339 | } catch (\CredisException $e) {
340 | // Prevent throwing exception if Sentinel has no password set (error messages are different between redis 5 and redis 6)
341 | if ($e->getCode() !== 0 || (
342 | strpos($e->getMessage(), 'ERR Client sent AUTH, but no password is set') === false &&
343 | strpos($e->getMessage(), 'ERR AUTH called without any password configured for the default user. Are you sure your configuration is correct?') === false)
344 | ) {
345 | throw $e;
346 | }
347 | }
348 | }
349 |
350 | $sentinel = new \Credis_Sentinel($sentinelClient);
351 | $sentinel
352 | ->setClientTimeout($timeout)
353 | ->setClientPersistent($persistent);
354 | $redisMaster = $sentinel->getMasterClient($sentinelMaster);
355 | if ($pass) $redisMaster->auth($pass, $username);
356 |
357 | // Verify connected server is actually master as per Sentinel client spec
358 | if ($sentinelVerifyMaster) {
359 | $roleData = $redisMaster->role();
360 | if ( ! $roleData || $roleData[0] != 'master') {
361 | usleep(100000); // Sleep 100ms and try again
362 | $redisMaster = $sentinel->getMasterClient($sentinelMaster);
363 | if ($pass) $redisMaster->auth($pass, $username);
364 | $roleData = $redisMaster->role();
365 | if ( ! $roleData || $roleData[0] != 'master') {
366 | throw new \Exception('Unable to determine master redis server.');
367 | }
368 | }
369 | }
370 | if (($this->_dbNum || $persistent) && !$this->_useCluster) $redisMaster->select(0);
371 |
372 | $this->_redis = $redisMaster;
373 | break 2;
374 | } catch (\Exception $e) {
375 | unset($sentinelClient);
376 | $exception = $e;
377 | }
378 | }
379 | unset($sentinel);
380 |
381 | if ( ! $this->_redis) {
382 | throw new ConnectionFailedException('Unable to connect to a Redis: '.$exception->getMessage(), 0, $exception);
383 | }
384 | }
385 | else {
386 | if (($config instanceof ClusterConfigInterface) && ($config->isCluster())) {
387 | $this->_redis = new \Credis_Cluster(
388 | $config->getClusterName(),
389 | $config->getClusterSeeds(),
390 | $timeout,
391 | 0,
392 | $config->getClusterUsePersistentConnection(),
393 | $pass,
394 | $username,
395 | $tlsOptions,
396 | );
397 | $this->_usePipeline = false;
398 | $this->_useCluster = true;
399 | } else {
400 | $this->_redis = new \Credis_Client(
401 | $host,
402 | $port,
403 | $timeout,
404 | $persistent,
405 | 0,
406 | $pass,
407 | $username,
408 | $tlsOptions
409 | );
410 | $this->_usePipeline = true;
411 | $this->_useCluster = false;
412 | }
413 | $this->_redis->setMaxConnectRetries($retries);
414 | if ($this->hasConnection() == false) {
415 | throw new ConnectionFailedException('Unable to connect to Redis');
416 | }
417 | }
418 | // Destructor order cannot be predicted
419 | $this->_redis->setCloseOnDestruct(false);
420 | if ($this->_useCluster) {
421 | $this->_log(
422 | sprintf(
423 | "%s initialized for connection to %s after %.5f seconds",
424 | get_class($this),
425 | (!empty($this->_redis->getClusterSeeds())) ?
426 | var_export($this->_redis->getClusterSeeds(), true) : $this->_redis->getClusterName(),
427 | (microtime(true) - $timeStart)
428 | )
429 | );
430 | } else {
431 | $this->_log(
432 | sprintf(
433 | "%s initialized for connection to %s:%s after %.5f seconds",
434 | get_class($this),
435 | $this->_redis->getHost(),
436 | $this->_redis->getPort(),
437 | (microtime(true) - $timeStart)
438 | )
439 | );
440 | }
441 | }
442 |
443 | /**
444 | * Open session
445 | *
446 | * @param string $savePath ignored
447 | * @param string $sessionName ignored
448 | * @return bool
449 | * @SuppressWarnings(PHPMD.UnusedFormalParameter)
450 | */
451 | #[\ReturnTypeWillChange]
452 | public function open($savePath, $sessionName)
453 | {
454 | return true;
455 | }
456 |
457 | /**
458 | * @param $msg
459 | * @param $level
460 | */
461 | protected function _log($msg, $level = LoggerInterface::DEBUG)
462 | {
463 | $this->logger->log("{$this->_getPid()}: $msg", $level);
464 | }
465 |
466 | /**
467 | * Check Redis connection
468 | *
469 | * @return bool
470 | */
471 | protected function hasConnection()
472 | {
473 | try {
474 | $this->_redis->connect();
475 | $this->_log("Connected to Redis");
476 | return true;
477 | } catch (\Exception $e) {
478 | $this->logger->logException($e);
479 | $this->_log('Unable to connect to Redis');
480 | return false;
481 | }
482 | }
483 |
484 | /**
485 | * Set/unset read only flag
486 | *
487 | * @param boolean $readOnly
488 | * @return self
489 | */
490 | public function setReadOnly($readOnly)
491 | {
492 | $this->_readOnly = $readOnly;
493 |
494 | return $this;
495 | }
496 |
497 | /**
498 | * Fetch session data
499 | *
500 | * @param string $sessionId
501 | * @return string
502 | * @throws ConcurrentConnectionsExceededException
503 | */
504 | #[\ReturnTypeWillChange]
505 | public function read($sessionId)
506 | {
507 | // Get lock on session. Increment the "lock" field and if the new value is 1, we have the lock.
508 | $sessionId = self::SESSION_PREFIX.$sessionId;
509 | $tries = $waiting = $lock = 0;
510 | $lockPid = $oldLockPid = null; // Restart waiting for lock when current lock holder changes
511 | $detectZombies = false;
512 | $breakAfter = $this->_getBreakAfter();
513 | $timeStart = microtime(true);
514 | $this->_log(sprintf("Attempting to take lock on ID %s", $sessionId));
515 |
516 | if (!$this->_useCluster) $this->_redis->select($this->_dbNum);
517 | while ($this->_useLocking && !$this->_readOnly)
518 | {
519 | // Increment lock value for this session and retrieve the new value
520 | $oldLock = $lock;
521 | $lock = $this->_redis->hIncrBy($sessionId, 'lock', 1);
522 |
523 | // Get the pid of the process that has the lock
524 | if ($lock != 1 && $tries + 1 >= $breakAfter) {
525 | $lockPid = $this->_redis->hGet($sessionId, 'pid');
526 | }
527 |
528 | // If we got the lock, update with our pid and reset lock and expiration
529 | if ( $lock == 1 // We actually do have the lock
530 | || (
531 | $tries >= $breakAfter // We are done waiting and want to start trying to break it
532 | && $oldLockPid == $lockPid // Nobody else got the lock while we were waiting
533 | )
534 | ) {
535 | $this->_hasLock = true;
536 | break;
537 | }
538 |
539 | // Otherwise, add to "wait" counter and continue
540 | else if ( ! $waiting) {
541 | $i = 0;
542 | do {
543 | $waiting = $this->_redis->hIncrBy($sessionId, 'wait', 1);
544 | } while (++$i < $this->_maxConcurrency && $waiting < 1);
545 | }
546 |
547 | // Handle overloaded sessions
548 | else {
549 | // Detect broken sessions (e.g. caused by fatal errors)
550 | if ($detectZombies) {
551 | $detectZombies = false;
552 | // Lock shouldn't be less than old lock (another process broke the lock)
553 | if ($lock > $oldLock
554 | // Lock should be old+waiting, otherwise there must be a dead process
555 | && $lock + 1 < $oldLock + $waiting
556 | ) {
557 | // Reset session to fresh state
558 | $this->_log(
559 | sprintf(
560 | "Detected zombie waiter after %.5f seconds for ID %s (%d waiting)",
561 | (microtime(true) - $timeStart),
562 | $sessionId, $waiting
563 | ),
564 | LoggerInterface::INFO
565 | );
566 | $waiting = $this->_redis->hIncrBy($sessionId, 'wait', -1);
567 | continue;
568 | }
569 | }
570 |
571 | // Limit concurrent lock waiters to prevent server resource hogging
572 | if ($waiting >= $this->_maxConcurrency) {
573 | // Overloaded sessions get 503 errors
574 | try {
575 | $this->_redis->hIncrBy($sessionId, 'wait', -1);
576 | $this->_sessionWritten = true; // Prevent session from getting written
577 | $sessionInfo = $this->_redis->hMGet($sessionId, ['writes','req']);
578 | } catch (Exception $e) {
579 | $this->_log("$e", LoggerInterface::WARNING);
580 | }
581 | $this->_log(
582 | sprintf(
583 | 'Session concurrency exceeded for ID %s; displaying HTTP 503 (%s waiting, %s total '
584 | . 'requests) - Locked URL: %s',
585 | $sessionId,
586 | $waiting,
587 | isset($sessionInfo['writes']) ? $sessionInfo['writes'] : '-',
588 | isset($sessionInfo['req']) ? $sessionInfo['req'] : '-'
589 | ),
590 | LoggerInterface::WARNING
591 | );
592 | throw new ConcurrentConnectionsExceededException();
593 | }
594 | }
595 |
596 | $tries++;
597 | $oldLockPid = $lockPid;
598 | $sleepTime = self::SLEEP_TIME;
599 |
600 | // Detect dead lock waiters
601 | if ($tries % self::DETECT_ZOMBIES == 1) {
602 | $detectZombies = true;
603 | $sleepTime += 10000; // sleep + 0.01 seconds
604 | }
605 | // Detect dead lock holder every 10 seconds (only works on same node as lock holder)
606 | if ($tries % self::DETECT_ZOMBIES == 0) {
607 | $this->_log(
608 | sprintf(
609 | "Checking for zombies after %.5f seconds of waiting...", (microtime(true) - $timeStart)
610 | )
611 | );
612 |
613 | $pid = $this->_redis->hGet($sessionId, 'pid');
614 | if ($pid && ! $this->_pidExists($pid)) {
615 | // Allow a live process to get the lock
616 | $this->_redis->hSet($sessionId, 'lock', 0);
617 | $this->_log(
618 | sprintf(
619 | "Detected zombie process (%s) for %s (%s waiting)",
620 | $pid, $sessionId, $waiting
621 | ),
622 | LoggerInterface::INFO
623 | );
624 | continue;
625 | }
626 | }
627 | // Timeout
628 | if ($tries >= $breakAfter + $this->_failAfter) {
629 | $this->_hasLock = false;
630 | $this->_log(
631 | sprintf(
632 | 'Giving up on read lock for ID %s after %.5f seconds (%d attempts)',
633 | $sessionId,
634 | (microtime(true) - $timeStart),
635 | $tries
636 | ),
637 | LoggerInterface::NOTICE
638 | );
639 | break;
640 | }
641 | else {
642 | $this->_log(
643 | sprintf(
644 | "Waiting %.2f seconds for lock on ID %s (%d tries, lock pid is %s, %.5f seconds elapsed)",
645 | $sleepTime / 1000000,
646 | $sessionId,
647 | $tries,
648 | $lockPid,
649 | (microtime(true) - $timeStart)
650 | )
651 | );
652 | usleep($sleepTime);
653 | }
654 | }
655 | $this->failedLockAttempts = $tries;
656 |
657 | // Session can be read even if it was not locked by this pid!
658 | $timeStart2 = microtime(true);
659 | list($sessionData, $sessionWrites) = array_values($this->_redis->hMGet($sessionId, array('data','writes')));
660 | $this->_log(sprintf("Data read for ID %s in %.5f seconds", $sessionId, (microtime(true) - $timeStart2)));
661 | $this->_sessionWrites = (int) $sessionWrites;
662 |
663 | // This process is no longer waiting for a lock
664 | if ($tries > 0) {
665 | $this->_redis->hIncrBy($sessionId, 'wait', -1);
666 | }
667 |
668 | // This process has the lock, save the pid
669 | if ($this->_hasLock) {
670 | $setData = array(
671 | 'pid' => $this->_getPid(),
672 | 'lock' => 1,
673 | );
674 |
675 | // Save request data in session so if a lock is broken we can know which page it was for debugging
676 | if (empty($_SERVER['REQUEST_METHOD'])) {
677 | $setData['req'] = @$_SERVER['SCRIPT_NAME'];
678 | } else {
679 | $setData['req'] = $_SERVER['REQUEST_METHOD']." ".@$_SERVER['SERVER_NAME'].@$_SERVER['REQUEST_URI'];
680 | }
681 | if ($lock != 1) {
682 | $this->_log(
683 | sprintf(
684 | "Successfully broke lock for ID %s after %.5f seconds (%d attempts). Lock: %d\nLast request of "
685 | . "broken lock: %s",
686 | $sessionId,
687 | (microtime(true) - $timeStart),
688 | $tries,
689 | $lock,
690 | $this->_redis->hGet($sessionId, 'req')
691 | ),
692 | LoggerInterface::INFO
693 | );
694 | }
695 | }
696 | if ($this->_usePipeline) {
697 | // Set session data and expiration
698 | $this->_redis->pipeline();
699 | }
700 | if ( ! empty($setData)) {
701 | $this->_redis->hMSet($sessionId, $setData);
702 | }
703 | $this->_redis->expire($sessionId, 3600*6); // Expiration will be set to correct value when session is written
704 | if ($this->_usePipeline) {
705 | $this->_redis->exec();
706 | }
707 | // Reset flag in case of multiple session read/write operations
708 | $this->_sessionWritten = false;
709 | return $sessionData ? (string) $this->_decodeData($sessionData) : '';
710 | }
711 |
712 | /**
713 | * Update session
714 | *
715 | * @param string $sessionId
716 | * @param string $sessionData
717 | * @return boolean
718 | */
719 | #[\ReturnTypeWillChange]
720 | public function write($sessionId, $sessionData)
721 | {
722 | if ($this->_sessionWritten || $this->_readOnly) {
723 | $this->_log(sprintf(($this->_sessionWritten ? "Repeated" : "Read-only") . " session write detected; skipping for ID %s", $sessionId));
724 | return true;
725 | }
726 | $this->_sessionWritten = true;
727 | $timeStart = microtime(true);
728 |
729 | // Do not overwrite the session if it is locked by another pid
730 | try {
731 | if ($this->_dbNum && !$this->_useCluster) $this->_redis->select($this->_dbNum); // Prevent conflicts with other connections?
732 |
733 | if ( ! $this->_useLocking
734 | || ( ! ($pid = $this->_redis->hGet('sess_'.$sessionId, 'pid')) || $pid == $this->_getPid())
735 | ) {
736 | $this->_writeRawSession($sessionId, $sessionData, $this->getLifeTime());
737 | $this->_log(sprintf("Data written to ID %s in %.5f seconds", $sessionId, (microtime(true) - $timeStart)));
738 |
739 | }
740 | else {
741 | if ($this->_hasLock) {
742 | $this->_log(sprintf("Did not write session for ID %s: another process took the lock.",
743 | $sessionId
744 | ), LoggerInterface::WARNING);
745 | } else {
746 | $this->_log(sprintf("Did not write session for ID %s: unable to acquire lock.",
747 | $sessionId
748 | ), LoggerInterface::WARNING);
749 | }
750 | }
751 | }
752 | catch(\Exception $e) {
753 | $this->logger->logException($e);
754 | return false;
755 | }
756 | return true;
757 | }
758 |
759 | /**
760 | * Destroy session
761 | *
762 | * @param string $sessionId
763 | * @return boolean
764 | */
765 | #[\ReturnTypeWillChange]
766 | public function destroy($sessionId)
767 | {
768 | $this->_log(sprintf("Destroying ID %s", $sessionId));
769 | if ($this->_usePipeline) {
770 | $this->_redis->pipeline();
771 | }
772 | if ($this->_dbNum && !$this->_useCluster) $this->_redis->select($this->_dbNum);
773 | $this->_redis->unlink(self::SESSION_PREFIX.$sessionId);
774 | if ($this->_usePipeline) {
775 | $this->_redis->exec();
776 | }
777 | return true;
778 | }
779 |
780 | /**
781 | * Overridden to prevent calling getLifeTime at shutdown
782 | *
783 | * @return bool
784 | */
785 | #[\ReturnTypeWillChange]
786 | public function close()
787 | {
788 | $this->_log("Closing connection");
789 | if ($this->_redis) $this->_redis->close();
790 | return true;
791 | }
792 |
793 | /**
794 | * Garbage collection
795 | *
796 | * @param int $maxLifeTime ignored
797 | * @return boolean
798 | */
799 | #[\ReturnTypeWillChange]
800 | public function gc($maxLifeTime)
801 | {
802 | return true;
803 | }
804 |
805 | /**
806 | * Get the number of failed lock attempts
807 | *
808 | * @return int
809 | */
810 | public function getFailedLockAttempts()
811 | {
812 | return $this->failedLockAttempts;
813 | }
814 |
815 | static public function isBotAgent($userAgent)
816 | {
817 | $isBot = !$userAgent || preg_match(self::BOT_REGEX, $userAgent);
818 |
819 | if (is_array(self::$_botCheckCallback) && isset(self::$_botCheckCallback[0]) && self::$_botCheckCallback[1] && method_exists(self::$_botCheckCallback[0], self::$_botCheckCallback[1])) {
820 | $isBot = (bool) call_user_func_array(self::$_botCheckCallback, [$userAgent, $isBot]);
821 | }
822 |
823 | return $isBot;
824 | }
825 |
826 | /**
827 | * Get lock lifetime
828 | *
829 | * @return int|mixed
830 | */
831 | protected function getLifeTime()
832 | {
833 | if (is_null($this->_lifeTime)) {
834 | $lifeTime = null;
835 |
836 | // Detect bots by user agent
837 | $botLifetime = is_null($this->config->getBotLifetime()) ? self::DEFAULT_BOT_LIFETIME : $this->config->getBotLifetime();
838 | if ($botLifetime) {
839 | $userAgent = empty($_SERVER['HTTP_USER_AGENT']) ? false : $_SERVER['HTTP_USER_AGENT'];
840 | if (self::isBotAgent($userAgent)) {
841 | $this->_log(sprintf("Bot detected for user agent: %s", $userAgent));
842 | $botFirstLifetime = is_null($this->config->getBotFirstLifetime()) ? self::DEFAULT_BOT_FIRST_LIFETIME : $this->config->getBotFirstLifetime();
843 | if ($this->_sessionWrites <= 1 && $botFirstLifetime) {
844 | $lifeTime = $botFirstLifetime * (1+$this->_sessionWrites);
845 | } else {
846 | $lifeTime = $botLifetime;
847 | }
848 | }
849 | }
850 |
851 | // Use different lifetime for first write
852 | if ($lifeTime === null && $this->_sessionWrites <= 1) {
853 | $firstLifetime = is_null($this->config->getFirstLifetime()) ? self::DEFAULT_FIRST_LIFETIME : $this->config->getFirstLifetime();
854 | if ($firstLifetime) {
855 | $lifeTime = $firstLifetime * (1+$this->_sessionWrites);
856 | }
857 | }
858 |
859 | // Neither bot nor first write
860 | if ($lifeTime === null) {
861 | $lifeTime = $this->config->getLifetime();
862 | }
863 |
864 | $this->_lifeTime = $lifeTime;
865 | if ($this->_lifeTime < $this->_minLifetime) {
866 | $this->_lifeTime = $this->_minLifetime;
867 | }
868 | if ($this->_lifeTime > $this->_maxLifetime) {
869 | $this->_lifeTime = $this->_maxLifetime;
870 | }
871 | }
872 | return $this->_lifeTime;
873 | }
874 |
875 | /**
876 | * Encode data
877 | *
878 | * @param string $data
879 | * @return string
880 | */
881 | protected function _encodeData($data)
882 | {
883 | $originalDataSize = strlen($data);
884 | if ($this->_compressionThreshold > 0 && $this->_compressionLibrary != 'none' && $originalDataSize >= $this->_compressionThreshold) {
885 | $this->_log(sprintf("Compressing %s bytes with %s", $originalDataSize,$this->_compressionLibrary));
886 | $timeStart = microtime(true);
887 | $prefix = ':'.substr($this->_compressionLibrary,0,2).':';
888 | switch($this->_compressionLibrary) {
889 | case 'snappy': $data = snappy_compress($data); break;
890 | case 'lzf': $data = lzf_compress($data); break;
891 | case 'lz4': $data = lz4_compress($data); $prefix = ':l4:'; break;
892 | case 'gzip': $data = gzcompress($data, 1); break;
893 | }
894 | if ($data) {
895 | $data = $prefix.$data;
896 | $this->_log(
897 | sprintf(
898 | "Data compressed by %.1f percent in %.5f seconds",
899 | ($originalDataSize == 0 ? 0 : (100 - (strlen($data) / $originalDataSize * 100))),
900 | (microtime(true) - $timeStart)
901 | )
902 | );
903 | } else {
904 | $this->_log(
905 | sprintf("Could not compress session data using %s", $this->_compressionLibrary),
906 | LoggerInterface::WARNING
907 | );
908 | }
909 | }
910 | return $data;
911 | }
912 |
913 | /**
914 | * Decode data
915 | *
916 | * @param string $data
917 | * @return string
918 | */
919 | protected function _decodeData($data)
920 | {
921 | switch (substr($data,0,4)) {
922 | // asking the data which library it uses allows for transparent changes of libraries
923 | case ':sn:': $data = snappy_uncompress(substr($data,4)); break;
924 | case ':lz:': $data = lzf_decompress(substr($data,4)); break;
925 | case ':l4:': $data = lz4_uncompress(substr($data,4)); break;
926 | case ':gz:': $data = gzuncompress(substr($data,4)); break;
927 | }
928 | return $data;
929 | }
930 |
931 | /**
932 | * Write session data to Redis
933 | *
934 | * @param $id
935 | * @param $data
936 | * @param $lifetime
937 | * @throws \Exception
938 | */
939 | protected function _writeRawSession($id, $data, $lifetime)
940 | {
941 | $sessionId = 'sess_' . $id;
942 | if ($this->_usePipeline) {
943 | $this->_redis->pipeline();
944 | }
945 | if (!$this->_useCluster) $this->_redis->select($this->_dbNum);
946 | $this->_redis->hMSet($sessionId, array(
947 | 'data' => $this->_encodeData($data),
948 | 'lock' => 0, // 0 so that next lock attempt will get 1
949 | ));
950 | $this->_redis->hIncrBy($sessionId, 'writes', 1);
951 | $this->_redis->expire($sessionId, min((int)$lifetime, (int)$this->_maxLifetime));
952 | if ($this->_usePipeline) {
953 | $this->_redis->exec();
954 | }
955 | }
956 |
957 | /**
958 | * Get pid
959 | *
960 | * @return string
961 | */
962 | protected function _getPid()
963 | {
964 | return gethostname().'|'.getmypid();
965 | }
966 |
967 | /**
968 | * Check if pid exists
969 | *
970 | * @param $pid
971 | * @return bool
972 | */
973 | protected function _pidExists($pid)
974 | {
975 | list($host,$pid) = explode('|', $pid);
976 | if (PHP_OS != 'Linux' || $host != gethostname()) {
977 | return true;
978 | }
979 | return @file_exists('/proc/'.$pid);
980 | }
981 |
982 | /**
983 | * Get break time, calculated later than other config settings due to requiring session name to be set
984 | *
985 | * @return int
986 | */
987 | protected function _getBreakAfter()
988 | {
989 | // Has break after already been calculated? Only fetch from config once, then reuse variable.
990 | if (!$this->_breakAfter) {
991 | // Fetch relevant setting from config using session name
992 | $this->_breakAfter = (float)($this->config->getBreakAfter() ?: self::DEFAULT_BREAK_AFTER);
993 | // Use sleep time multiplier so break time is in seconds
994 | $this->_breakAfter = (int)round((1000000 / self::SLEEP_TIME) * $this->_breakAfter);
995 | }
996 |
997 | return $this->_breakAfter;
998 | }
999 | }
1000 |
--------------------------------------------------------------------------------