├── .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 | --------------------------------------------------------------------------------