Add configurable session garbage collection (gcProbability/gcDivisor) (#258)
Make session GC probability configurable via gcProbability and gcDivisor parameters, mirroring PHP's session.gc_probability/session.gc_divisor. Exposed through both SessionManager constructor and Builder::setSession(). Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
73414d65f6
commit
5e0731f706
5 changed files with 155 additions and 7 deletions
|
|
@ -6,6 +6,7 @@ All notable changes to `mcp/sdk` will be documented in this file.
|
|||
-----
|
||||
|
||||
* Allow overriding the default name pattern for Discovery
|
||||
* Add configurable session garbage collection (`gcProbability`/`gcDivisor`)
|
||||
|
||||
0.5.0
|
||||
-----
|
||||
|
|
|
|||
|
|
@ -156,11 +156,6 @@ use Mcp\Server\Session\Psr16SessionStore;
|
|||
use Symfony\Component\Cache\Psr16Cache;
|
||||
use Symfony\Component\Cache\Adapter\RedisAdapter;
|
||||
|
||||
// Use default in-memory sessions with custom TTL
|
||||
$server = Server::builder()
|
||||
->setSession(ttl: 7200) // 2 hours
|
||||
->build();
|
||||
|
||||
// Override with file-based storage
|
||||
$server = Server::builder()
|
||||
->setSession(new FileSessionStore(__DIR__ . '/sessions'))
|
||||
|
|
@ -188,6 +183,45 @@ $server = Server::builder()
|
|||
->build();
|
||||
```
|
||||
|
||||
### Garbage Collection Configuration
|
||||
|
||||
The SDK periodically runs garbage collection to clean up expired sessions, similar to PHP's native
|
||||
`session.gc_probability` and `session.gc_divisor` settings. The probability that GC runs on any given
|
||||
request is `gcProbability / gcDivisor`.
|
||||
|
||||
```php
|
||||
// Default: 1/100 (1% chance per request)
|
||||
$server = Server::builder()
|
||||
->setSession(new FileSessionStore(__DIR__ . '/sessions'))
|
||||
->build();
|
||||
|
||||
// Higher frequency: 1/10 (10% chance per request)
|
||||
$server = Server::builder()
|
||||
->setSession(
|
||||
new FileSessionStore(__DIR__ . '/sessions'),
|
||||
gcProbability: 1,
|
||||
gcDivisor: 10,
|
||||
)
|
||||
->build();
|
||||
|
||||
// Run GC on every request
|
||||
$server = Server::builder()
|
||||
->setSession(gcProbability: 1, gcDivisor: 1)
|
||||
->build();
|
||||
|
||||
// Disable GC entirely (e.g. when using an external cleanup process)
|
||||
$server = Server::builder()
|
||||
->setSession(gcProbability: 0)
|
||||
->build();
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
- `$gcProbability` (int): The numerator of the GC probability fraction (default: `1`). Set to `0` to disable GC.
|
||||
- `$gcDivisor` (int): The denominator of the GC probability fraction (default: `100`). Must be >= 1.
|
||||
|
||||
> **Note**: When providing a custom `SessionManagerInterface` via the `$sessionManager` parameter,
|
||||
> the `gcProbability` and `gcDivisor` settings are ignored — you control GC behavior in your own implementation.
|
||||
|
||||
**Available Session Stores:**
|
||||
- `InMemorySessionStore`: Fast in-memory storage (default)
|
||||
- `FileSessionStore`: Persistent file-based storage
|
||||
|
|
@ -573,7 +607,7 @@ $server = Server::builder()
|
|||
| `setPaginationLimit()` | limit | Set max items per page |
|
||||
| `setInstructions()` | instructions | Set usage instructions |
|
||||
| `setDiscovery()` | basePath, scanDirs?, excludeDirs?, cache? | Configure attribute discovery |
|
||||
| `setSession()` | store?, factory?, ttl? | Configure session management |
|
||||
| `setSession()` | sessionStore?, sessionManager?, gcProbability?, gcDivisor? | Configure session management |
|
||||
| `setLogger()` | logger | Set PSR-3 logger |
|
||||
| `setContainer()` | container | Set PSR-11 container |
|
||||
| `setEventDispatcher()` | dispatcher | Set PSR-14 event dispatcher |
|
||||
|
|
|
|||
|
|
@ -79,6 +79,10 @@ final class Builder
|
|||
|
||||
private ?SessionStoreInterface $sessionStore = null;
|
||||
|
||||
private int $gcProbability = 1;
|
||||
|
||||
private int $gcDivisor = 100;
|
||||
|
||||
private int $paginationLimit = 50;
|
||||
|
||||
private ?string $instructions = null;
|
||||
|
|
@ -336,12 +340,22 @@ final class Builder
|
|||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configures the session layer.
|
||||
*
|
||||
* @param int $gcProbability The numerator of the GC probability fraction (like PHP's session.gc_probability). Set to 0 to disable GC.
|
||||
* @param int $gcDivisor The denominator of the GC probability fraction (like PHP's session.gc_divisor). Probability = gcProbability/gcDivisor.
|
||||
*/
|
||||
public function setSession(
|
||||
?SessionStoreInterface $sessionStore = null,
|
||||
?SessionManagerInterface $sessionManager = null,
|
||||
int $gcProbability = 1,
|
||||
int $gcDivisor = 100,
|
||||
): self {
|
||||
$this->sessionStore = $sessionStore;
|
||||
$this->sessionManager = $sessionManager;
|
||||
$this->gcProbability = $gcProbability;
|
||||
$this->gcDivisor = $gcDivisor;
|
||||
|
||||
if (null !== $sessionManager && null !== $sessionStore) {
|
||||
throw new InvalidArgumentException('Cannot set both SessionStore and SessionManager. Set only one or the other.');
|
||||
|
|
@ -534,6 +548,8 @@ final class Builder
|
|||
$sessionManager = $this->sessionManager ?? new SessionManager(
|
||||
$this->sessionStore ?? new InMemorySessionStore(),
|
||||
$logger,
|
||||
$this->gcProbability,
|
||||
$this->gcDivisor,
|
||||
);
|
||||
|
||||
if (null !== $this->discoveryBasePath) {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
|
||||
namespace Mcp\Server\Session;
|
||||
|
||||
use Mcp\Exception\InvalidArgumentException;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Psr\Log\NullLogger;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
|
@ -22,10 +23,22 @@ use Symfony\Component\Uid\Uuid;
|
|||
*/
|
||||
class SessionManager implements SessionManagerInterface
|
||||
{
|
||||
/**
|
||||
* @param int $gcProbability The probability (numerator) that GC will run on any given request. Combined with $gcDivisor to calculate the actual probability. Set to 0 to disable GC. Similar to PHP's session.gc_probability.
|
||||
* @param int $gcDivisor The divisor used with $gcProbability to calculate GC probability. The probability is gcProbability/gcDivisor (e.g. 1/100 = 1%). Similar to PHP's session.gc_divisor.
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly SessionStoreInterface $store,
|
||||
private readonly LoggerInterface $logger = new NullLogger(),
|
||||
private readonly int $gcProbability = 1,
|
||||
private readonly int $gcDivisor = 100,
|
||||
) {
|
||||
if ($gcProbability < 0) {
|
||||
throw new InvalidArgumentException('gcProbability must be greater than or equal to 0.');
|
||||
}
|
||||
if ($gcDivisor < 1) {
|
||||
throw new InvalidArgumentException('gcDivisor must be greater than or equal to 1.');
|
||||
}
|
||||
}
|
||||
|
||||
public function create(): SessionInterface
|
||||
|
|
@ -54,7 +67,11 @@ class SessionManager implements SessionManagerInterface
|
|||
*/
|
||||
public function gc(): void
|
||||
{
|
||||
if (random_int(0, 100) > 1) {
|
||||
if (0 === $this->gcProbability) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (random_int(1, $this->gcDivisor) > $this->gcProbability) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
80
tests/Unit/Server/Session/SessionManagerTest.php
Normal file
80
tests/Unit/Server/Session/SessionManagerTest.php
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the official PHP MCP SDK.
|
||||
*
|
||||
* A collaboration between Symfony and the PHP Foundation.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Mcp\Tests\Unit\Server\Session;
|
||||
|
||||
use Mcp\Exception\InvalidArgumentException;
|
||||
use Mcp\Server\Session\InMemorySessionStore;
|
||||
use Mcp\Server\Session\SessionManager;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class SessionManagerTest extends TestCase
|
||||
{
|
||||
public function testGcDisabledWhenProbabilityIsZero(): void
|
||||
{
|
||||
$store = $this->createMock(InMemorySessionStore::class);
|
||||
$store->expects($this->never())->method('gc');
|
||||
|
||||
$manager = new SessionManager($store, gcProbability: 0);
|
||||
|
||||
// Call gc many times — it should never trigger
|
||||
for ($i = 0; $i < 100; ++$i) {
|
||||
$manager->gc();
|
||||
}
|
||||
}
|
||||
|
||||
public function testGcAlwaysRunsWhenProbabilityEqualsDivisor(): void
|
||||
{
|
||||
$store = $this->createMock(InMemorySessionStore::class);
|
||||
$store->expects($this->exactly(10))->method('gc')->willReturn([]);
|
||||
|
||||
$manager = new SessionManager($store, gcProbability: 1, gcDivisor: 1);
|
||||
|
||||
for ($i = 0; $i < 10; ++$i) {
|
||||
$manager->gc();
|
||||
}
|
||||
}
|
||||
|
||||
public function testGcAlwaysRunsWhenProbabilityExceedsDivisor(): void
|
||||
{
|
||||
$store = $this->createMock(InMemorySessionStore::class);
|
||||
$store->expects($this->exactly(5))->method('gc')->willReturn([]);
|
||||
|
||||
$manager = new SessionManager($store, gcProbability: 100, gcDivisor: 1);
|
||||
|
||||
for ($i = 0; $i < 5; ++$i) {
|
||||
$manager->gc();
|
||||
}
|
||||
}
|
||||
|
||||
public function testGcProbabilityMustBeNonNegative(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('gcProbability must be greater than or equal to 0.');
|
||||
|
||||
new SessionManager(new InMemorySessionStore(), gcProbability: -1);
|
||||
}
|
||||
|
||||
public function testGcDivisorMustBePositive(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('gcDivisor must be greater than or equal to 1.');
|
||||
|
||||
new SessionManager(new InMemorySessionStore(), gcDivisor: 0);
|
||||
}
|
||||
|
||||
public function testDefaultGcConfiguration(): void
|
||||
{
|
||||
// Default should be 1/100 — just verify construction works
|
||||
$manager = new SessionManager(new InMemorySessionStore());
|
||||
$this->assertInstanceOf(SessionManager::class, $manager);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue