diff --git a/docs/1-essentials/01-routing.md b/docs/1-essentials/01-routing.md index a2936ddf1a..e2484b9820 100644 --- a/docs/1-essentials/01-routing.md +++ b/docs/1-essentials/01-routing.md @@ -919,7 +919,9 @@ public function store(Todo $todo): Redirect ### Session configuration -Tempest supports file and database-based sessions, the former being the default option. Sessions can be configured by creating a `session.config.php` file, in which the expiration time and the session driver can be specified. +Tempest supports file, Redis and database-based sessions, the former being the default option. + +Sessions can be configured by creating a `session.config.php` file [anywhere](../1-essentials/06-configuration.md#configuration-files), in which the expiration time and the [clean up strategy](#session-cleaning) can be configured. #### File sessions @@ -939,13 +941,13 @@ return new FileSessionConfig( Tempest provides a database-based session driver, particularly useful for applications that run on multiple servers, as session data can be shared across all instances. -Before using database sessions, a dedicated table is needed. Tempest provides a migration that can be installed using its installer: +Before using database sessions, a dedicated table is needed. Tempest provides a dedicated sessions installer that can publish file, database, or Redis session configuration: ```sh -./tempest install sessions:database +./tempest install sessions ``` -This installer also suggests creating the configuration file that sets up database sessions, with a default expiration of 30 days: +When choosing the database strategy, the installer can also publish a migration and the configuration file that sets up database sessions, with a default expiration of 30 days: ```php app/Sessions/session.config.php use Tempest\Http\Session\Config\DatabaseSessionConfig; @@ -960,7 +962,61 @@ return new DatabaseSessionConfig( Sessions expire based on the last activity time. This means that as long as a user is actively using the application, their session remains valid. -Outdated sessions must occasionally be cleaned up. Tempest provides a built-in command to do so, `session:clean`. This command uses the [scheduler](../2-features/11-scheduling.md): with scheduling enabled, it automatically runs behind the scenes. +By default, Tempest removes expired session randomly at the end of a request, in a deferred task. This can be configured by specifying a {b`Tempest\Http\Session\CleanupStrategy`} in the [session configuration](#session-configuration). + +```php app/Sessions/session.config.php +use Tempest\Http\Session\Config\DatabaseSessionConfig; +use Tempest\DateTime\Duration; + +return new DatabaseSessionConfig( + expiration: Duration::days(30), + cleanupStrategy: CleanupStrategy::EVERY_REQUEST, +); +``` + +The default behavior is great for most applications. However, at a certain scale, the performance of random cleanup can decrease as the number of sessions grows. In that case, it is recommended to disable request-based session cleaning and switch to a scheduled cleanup strategy: + +:::code-group + +```php app/Sessions/session.config.php +use Tempest\Http\Session\Config\DatabaseSessionConfig; +use Tempest\DateTime\Duration; + +return new DatabaseSessionConfig( + expiration: Duration::days(30), + cleanupStrategy: CleanupStrategy::DISABLED, +); +``` + +```php app/Sessions/CleanupSessionsCommand.php +namespace App\Sessions; + +use Tempest\Console\ConsoleCommand; +use Tempest\Console\Schedule; +use Tempest\Console\Scheduler\Every; +use Tempest\Http\Session\SessionManager; + +final readonly class CleanupSessionsCommand +{ + public function __construct( + private SessionManager $sessionManager, + ) { + } + + #[ConsoleCommand(name: 'session:clean')] + #[Schedule(Every::MINUTE)] + public function __invoke(): void + { + $this->sessionManager->deleteExpiredSessions(); + } +} +``` + +::: + +:::info +This cleanup command can be installed in your codebase by running the `./tempest install sessions` command. +::: ## Deferring tasks diff --git a/docs/1-essentials/06-configuration.md b/docs/1-essentials/06-configuration.md index 8cc1d6dbbb..a72ec77b14 100644 --- a/docs/1-essentials/06-configuration.md +++ b/docs/1-essentials/06-configuration.md @@ -33,18 +33,18 @@ The configuration object above instructs Tempest to use PostgreSQL as its databa To access a configuration object, you may inject it from the container like any other dependency. ```php -use Tempest\Core\Environment; +use Tempest\Core\AppConfig; final readonly class AboutController { public function __construct( - private Environment $environment, + private AppConfig $config, ) {} #[Get('/')] public function __invoke(): View { - return view('about.view.php', environment: $this->environment); + return view('about.view.php', name: $this->config->name); } } ``` diff --git a/packages/core/src/PublishesFiles.php b/packages/core/src/PublishesFiles.php index 439fce69dc..c6443c942a 100644 --- a/packages/core/src/PublishesFiles.php +++ b/packages/core/src/PublishesFiles.php @@ -58,15 +58,20 @@ trait PublishesFiles * @param string $source The path to the source file. * @param string $destination The path to the destination file. * @param Closure(string $source, string $destination): void|null $callback A callback to run after the file is published. + * @param bool $confirm Whether to ask for create confirmation before publishing. */ - public function publish(string $source, string $destination, ?Closure $callback = null): string|false + public function publish(string $source, string $destination, ?Closure $callback = null, bool $confirm = true): string|false { try { - if (! $this->console->confirm( - question: sprintf('Do you want to create ?', $this->friendlyFileName($destination)), - default: true, - )) { - throw new FileGenerationWasAborted('Skipped.'); + if ($confirm) { + $shouldContinue = $this->console->confirm( + question: sprintf('Do you want to create ?', $this->friendlyFileName($destination)), + default: true, + ); + + if (! $shouldContinue) { + throw new FileGenerationWasAborted('Skipped.'); + } } if (! $this->askForOverride($destination)) { diff --git a/packages/http/src/Session/CleanupSessionsCommand.php b/packages/http/src/Session/CleanupSessionsCommand.php index a7335ceda3..2ec64f03e6 100644 --- a/packages/http/src/Session/CleanupSessionsCommand.php +++ b/packages/http/src/Session/CleanupSessionsCommand.php @@ -8,8 +8,10 @@ use Tempest\Console\ConsoleCommand; use Tempest\Console\Schedule; use Tempest\Console\Scheduler\Every; +use Tempest\Discovery\SkipDiscovery; use Tempest\EventBus\EventBus; +#[SkipDiscovery] final readonly class CleanupSessionsCommand { public function __construct( diff --git a/packages/http/src/Session/CleanupStrategy.php b/packages/http/src/Session/CleanupStrategy.php new file mode 100644 index 0000000000..e08b709e33 --- /dev/null +++ b/packages/http/src/Session/CleanupStrategy.php @@ -0,0 +1,21 @@ +publish( - source: __DIR__ . '/CreateSessionsTable.php', - destination: src_path('Sessions/CreateSessionsTable.php'), - ); - - $this->publish( - source: __DIR__ . '/session.config.stub.php', - destination: src_path('Sessions/session.config.php'), - ); - - $this->publishImports(); - - if ($migration && $this->shouldMigrate()) { - $this->migrationManager->up(); - } - } - - private function shouldMigrate(): bool - { - $argument = $this->consoleArgumentBag->get('migrate'); - - if (! $argument instanceof ConsoleInputArgument || ! is_bool($argument->value)) { - return $this->console->confirm('Do you want to execute migrations?'); - } - - return (bool) $argument->value; - } -} diff --git a/packages/http/src/Session/Installer/SessionInstaller.php b/packages/http/src/Session/Installer/SessionInstaller.php new file mode 100644 index 0000000000..a4f22e6a7f --- /dev/null +++ b/packages/http/src/Session/Installer/SessionInstaller.php @@ -0,0 +1,200 @@ +resolveSessionDriver(); + $cleanupStrategy = $sessionStrategy === self::REDIS + ? null + : $this->resolveCleanupStrategy(); + + $configPath = $this->resolveTargetPath( + argumentName: 'config-path', + defaultPath: src_path('Sessions/session.config.php'), + ); + + $migration = $sessionStrategy === self::DATABASE + ? $this->publish( + source: __DIR__ . '/CreateSessionsTable.php', + destination: src_path('Sessions/CreateSessionsTable.php'), + ) + : null; + + $this->publish( + source: $this->resolveSessionConfigStub($sessionStrategy, $cleanupStrategy), + destination: $configPath, + confirm: false, + ); + + if ($cleanupStrategy && $this->shouldPublishCleanupCommand($cleanupStrategy)) { + $this->publish( + source: __DIR__ . '/../CleanupSessionsCommand.php', + destination: $this->resolveTargetPath( + argumentName: 'cleanup-command-path', + defaultPath: src_path('Sessions/CleanupSessionsCommand.php'), + ), + confirm: false, + ); + } + + $this->publishImports(); + + if ($migration && $this->shouldMigrate()) { + $this->migrationManager->up(); + } + } + + private function resolveSessionDriver(): string + { + $argument = $this->consoleArgumentBag->get('strategy'); + + if ($argument instanceof ConsoleInputArgument && is_string($argument->value)) { + return match (strtolower(trim($argument->value))) { + self::FILE => self::FILE, + self::DATABASE => self::DATABASE, + self::REDIS => self::REDIS, + default => throw new InvalidArgumentException('Invalid session storage strategy: ' . $argument->value), + }; + } + + return $this->ask( + question: 'Which session storage strategy do you want to use?', + options: [ + self::FILE => 'File', + self::DATABASE => 'Database', + self::REDIS => 'Redis', + ], + default: self::FILE, + ); + } + + private function resolveTargetPath(string $argumentName, string $defaultPath): string + { + $argument = $this->consoleArgumentBag->get($argumentName); + + if ($argument instanceof ConsoleInputArgument && is_string($argument->value)) { + return to_absolute_path(root_path(), $argument->value); + } + + return $this->promptTargetPath($defaultPath); + } + + private function resolveCleanupStrategy(): CleanupStrategy + { + $argument = $this->consoleArgumentBag->get('cleanup-strategy'); + + if ($argument instanceof ConsoleInputArgument && is_string($argument->value)) { + $strategy = $this->parseCleanupStrategy($argument->value); + + if ($strategy instanceof CleanupStrategy) { + return $strategy; + } + } + + /** @var string $selection */ + $selection = $this->ask( + question: 'Which session cleanup strategy do you want to use?', + options: [ + CleanupStrategy::RANDOM_REQUESTS->name => 'Random requests', + CleanupStrategy::EVERY_REQUEST->name => 'Every request', + CleanupStrategy::DISABLED->name => 'Disabled (schedule the cleanup command)', + ], + default: CleanupStrategy::RANDOM_REQUESTS->name, + ); + + return $this->parseCleanupStrategy($selection); + } + + private function parseCleanupStrategy(string $input): ?CleanupStrategy + { + $normalized = strtoupper(str_replace(['-', ' '], '_', $input)); + + return match ($normalized) { + CleanupStrategy::EVERY_REQUEST->name => CleanupStrategy::EVERY_REQUEST, + CleanupStrategy::RANDOM_REQUESTS->name => CleanupStrategy::RANDOM_REQUESTS, + CleanupStrategy::DISABLED->name => CleanupStrategy::DISABLED, + default => null, + }; + } + + private function resolveSessionConfigStub(string $sessionStrategy, ?CleanupStrategy $cleanupStrategy): string + { + if ($sessionStrategy === self::REDIS) { + return __DIR__ . '/session.redis.config.stub.php'; + } + + return match ([$sessionStrategy, $cleanupStrategy]) { + [self::FILE, CleanupStrategy::EVERY_REQUEST] => __DIR__ . '/session.file.every-request.config.stub.php', + [self::FILE, CleanupStrategy::RANDOM_REQUESTS] => __DIR__ . '/session.file.random-requests.config.stub.php', + [self::FILE, CleanupStrategy::DISABLED] => __DIR__ . '/session.file.disabled.config.stub.php', + [self::DATABASE, CleanupStrategy::EVERY_REQUEST] => __DIR__ . '/session.database.every-request.config.stub.php', + [self::DATABASE, CleanupStrategy::RANDOM_REQUESTS] => __DIR__ . '/session.database.random-requests.config.stub.php', + [self::DATABASE, CleanupStrategy::DISABLED] => __DIR__ . '/session.database.disabled.config.stub.php', + default => throw new LogicException('Cleanup strategy must be provided for non-Redis session drivers.'), + }; + } + + private function shouldMigrate(): bool + { + $argument = $this->consoleArgumentBag->get('migrate'); + + if (! $argument instanceof ConsoleInputArgument || ! is_bool($argument->value)) { + return $this->console->confirm('Do you want to execute migrations?', default: false); + } + + return (bool) $argument->value; + } + + private function shouldPublishCleanupCommand(CleanupStrategy $cleanupStrategy): bool + { + $argument = $this->consoleArgumentBag->get('cleanup-command'); + + if ($argument instanceof ConsoleInputArgument && is_bool($argument->value)) { + return (bool) $argument->value; + } + + if ($cleanupStrategy !== CleanupStrategy::DISABLED) { + return false; + } + + return $this->console->confirm( + 'Session cleanup is disabled. Do you want to publish a session cleanup command?', + default: true, + ); + } +} diff --git a/packages/http/src/Session/Installer/session.config.stub.php b/packages/http/src/Session/Installer/session.database.disabled.config.stub.php similarity index 69% rename from packages/http/src/Session/Installer/session.config.stub.php rename to packages/http/src/Session/Installer/session.database.disabled.config.stub.php index e2feecf0ed..162c7390cf 100644 --- a/packages/http/src/Session/Installer/session.config.stub.php +++ b/packages/http/src/Session/Installer/session.database.disabled.config.stub.php @@ -1,9 +1,11 @@ session->cleanup(); $this->sessionManager->save($this->session); - $this->sessionManager->deleteExpiredSessions(); + + match ($this->config->cleanupStrategy) { + CleanupStrategy::EVERY_REQUEST => $this->scheduleSessionCleanup(), + CleanupStrategy::RANDOM_REQUESTS => $this->maybeScheduleSessionCleanup(), + default => null, + }; + } + } + + private function scheduleSessionCleanup(): void + { + $this->deferredTasks->add( + task: $this->sessionManager->deleteExpiredSessions(...), + name: 'tempest:session-cleanup', + ); + } + + private function maybeScheduleSessionCleanup(): void + { + if (random_int(min: 1, max: 50) === 1) { + $this->scheduleSessionCleanup(); } } } diff --git a/packages/http/src/Session/SessionConfig.php b/packages/http/src/Session/SessionConfig.php index 425d34025a..50a82639a8 100644 --- a/packages/http/src/Session/SessionConfig.php +++ b/packages/http/src/Session/SessionConfig.php @@ -12,5 +12,10 @@ interface SessionConfig */ public Duration $expiration { get; } + /** + * Defines the strategy used to clean up expired sessions. The default strategy is `RANDOM_REQUESTS`, which provides a good balance between performance and cleanup frequency. + */ + public CleanupStrategy $cleanupStrategy { get; } + public function createManager(Container $container): SessionManager; } diff --git a/tests/Integration/Console/Middleware/ResolveOrRescueMiddlewareTest.php b/tests/Integration/Console/Middleware/ResolveOrRescueMiddlewareTest.php index 09c4955b3f..4b366eedcc 100644 --- a/tests/Integration/Console/Middleware/ResolveOrRescueMiddlewareTest.php +++ b/tests/Integration/Console/Middleware/ResolveOrRescueMiddlewareTest.php @@ -43,13 +43,11 @@ public function test_similar_commands(): void ->call('clear') ->assertSee('cache:clear') ->assertSee('discovery:clear') - ->assertSee('static:clean') - ->assertSee('session:clean'); + ->assertSee('static:clean'); $this->console ->call('clean') ->assertSee('static:clean') - ->assertSee('session:clean') ->assertSee('cache:clear') ->assertSee('discovery:clear'); } diff --git a/tests/Integration/Console/Scheduler/ScheduleRunCommandTest.php b/tests/Integration/Console/Scheduler/ScheduleRunCommandTest.php index 1a81311caf..836bc2ea4b 100644 --- a/tests/Integration/Console/Scheduler/ScheduleRunCommandTest.php +++ b/tests/Integration/Console/Scheduler/ScheduleRunCommandTest.php @@ -21,13 +21,11 @@ public function test_invoke(): void $this->console ->call('schedule:run') ->assertSee('scheduled') - ->assertSee('schedule:task Tests\\\\Tempest\\\\Integration\\\\Console\\\\Fixtures\\\\ScheduledCommand::method') - ->assertSee('session:clean'); + ->assertSee('schedule:task Tests\\\\Tempest\\\\Integration\\\\Console\\\\Fixtures\\\\ScheduledCommand::method'); $this->console ->call('schedule:run') ->assertNotSee('scheduled') - ->assertNotSee('schedule:task Tests\\\\Tempest\\\\Integration\\\\Console\\\\Fixtures\\\\ScheduledCommand::method') - ->assertNotSee('session:clean'); + ->assertNotSee('schedule:task Tests\\\\Tempest\\\\Integration\\\\Console\\\\Fixtures\\\\ScheduledCommand::method'); } } diff --git a/tests/Integration/Http/CleanupSessionsCommandTest.php b/tests/Integration/Http/CleanupSessionsCommandTest.php deleted file mode 100644 index 982297fb45..0000000000 --- a/tests/Integration/Http/CleanupSessionsCommandTest.php +++ /dev/null @@ -1,59 +0,0 @@ -clock('2024-01-01 00:00:00'); - - $this->container->config(new FileSessionConfig( - expiration: Duration::seconds(10), - path: 'tests/sessions', - )); - - $sessionManager = $this->container->get(SessionManager::class); - - $sessionA = $sessionManager->getOrCreate(new SessionId('session_a')); - $sessionA->set('test', 'value'); - - $sessionManager->save($sessionA); - - $clock->plus(Duration::seconds(9)); - - $sessionB = $sessionManager->getOrCreate(new SessionId('session_b')); - $sessionB->set('test', 'value'); - - $sessionManager->save($sessionB); - - $clock->plus(Duration::seconds(2)); - - $this->console - ->call(CleanupSessionsCommand::class) - ->assertContains('session_a') - ->assertDoesNotContain('session_b'); - - $this->assertFileDoesNotExist(internal_storage_path('/tests/sessions/session_a')); - $this->assertFileExists(internal_storage_path('/tests/sessions/session_b')); - } -} diff --git a/tests/Integration/Http/DatabaseSessionInstallerTest.php b/tests/Integration/Http/DatabaseSessionInstallerTest.php deleted file mode 100644 index a085634ee5..0000000000 --- a/tests/Integration/Http/DatabaseSessionInstallerTest.php +++ /dev/null @@ -1,41 +0,0 @@ -installer - ->configure($this->internalStorage . '/install', new Psr4Namespace('App\\', $this->internalStorage . '/install/App')) - ->setRoot($this->internalStorage . '/install'); - } - - #[PostCondition] - protected function cleanup(): void - { - $this->installer->clean(); - } - - #[Test] - public function installer(): void - { - $this->console->call('install sessions:database -f --no-migrate'); - - $this->installer - ->assertFileExists('App/Sessions/CreateSessionsTable.php') - ->assertFileExists('App/Sessions/session.config.php'); - } -} diff --git a/tests/Integration/Http/SessionCleanupStrategyTest.php b/tests/Integration/Http/SessionCleanupStrategyTest.php new file mode 100644 index 0000000000..444ac468f2 --- /dev/null +++ b/tests/Integration/Http/SessionCleanupStrategyTest.php @@ -0,0 +1,139 @@ +clearDeferredTasks(); + } + + #[Test] + public function every_request_schedules_cleanup_task(): void + { + $this->invokeMiddlewareWith(new FileSessionConfig( + expiration: Duration::minutes(30), + cleanupStrategy: CleanupStrategy::EVERY_REQUEST, + path: 'tests/sessions', + )); + + $this->assertArrayHasKey( + 'tempest:session-cleanup', + $this->container->get(DeferredTasks::class)->getTasks(), + ); + } + + #[Test] + public function disabled_does_not_schedule_cleanup_task(): void + { + $this->invokeMiddlewareWith(new FileSessionConfig( + expiration: Duration::minutes(30), + cleanupStrategy: CleanupStrategy::DISABLED, + path: 'tests/sessions', + )); + + $this->assertArrayNotHasKey( + 'tempest:session-cleanup', + $this->container->get(DeferredTasks::class)->getTasks(), + ); + } + + private function invokeMiddlewareWith(FileSessionConfig $config): void + { + $this->clearDeferredTasks(); + + $clock = $this->container->get(Clock::class); + $now = $clock->now(); + + $session = new Session( + id: new SessionId('strategy-test'), + createdAt: $now, + lastActiveAt: $now, + ); + + $manager = new TestingSessionManager($clock); + + $middleware = new ManageSessionMiddleware( + sessionManager: $manager, + session: $session, + config: $config, + deferredTasks: $this->container->get(DeferredTasks::class), + ); + + $response = $middleware( + request: new GenericRequest(method: Method::GET, uri: '/'), + next: new HttpMiddlewareCallable(fn () => new GenericResponse(Status::OK)), + ); + + $this->assertSame(Status::OK, $response->status); + $this->assertSame(1, $manager->saveCalls); + } + + private function clearDeferredTasks(): void + { + $deferredTasks = $this->container->get(DeferredTasks::class); + + foreach (array_keys($deferredTasks->getTasks()) as $name) { + $deferredTasks->forget($name); + } + } +} + +final class TestingSessionManager implements SessionManager +{ + public int $saveCalls = 0; + + public function __construct( + private readonly Clock $clock, + ) {} + + public function getOrCreate(SessionId $id): Session + { + $now = $this->clock->now(); + + return new Session( + id: $id, + createdAt: $now, + lastActiveAt: $now, + ); + } + + public function save(Session $session): void + { + $this->saveCalls++; + } + + public function delete(Session $session): void {} + + public function isValid(Session $session): bool + { + return true; + } + + public function deleteExpiredSessions(): void {} +} diff --git a/tests/Integration/Http/SessionInstallerTest.php b/tests/Integration/Http/SessionInstallerTest.php new file mode 100644 index 0000000000..ba75dfcdd5 --- /dev/null +++ b/tests/Integration/Http/SessionInstallerTest.php @@ -0,0 +1,119 @@ +installer + ->configure(__DIR__ . '/install', new Psr4Namespace('App\\', __DIR__ . '/install/App')) + ->setRoot(__DIR__ . '/install'); + } + + #[PostCondition] + protected function cleanup(): void + { + $this->installer->clean(); + } + + #[Test] + public function installs_database_session_config_and_migration(): void + { + $this->console + ->call('install sessions') + ->confirm() + ->input(1) + ->input(0) + ->input('App/Sessions/session.config.php') + ->confirm() + ->deny() + ->assertSuccess(); + + $this->installer + ->assertFileExists('App/Sessions/CreateSessionsTable.php') + ->assertFileExists('App/Sessions/session.config.php'); + + $this->installer->assertFileContains('App/Sessions/session.config.php', 'DatabaseSessionConfig'); + } + + #[Test] + public function installs_file_session_config(): void + { + $this->console + ->call('install sessions') + ->confirm() + ->input(0) + ->input(0) + ->input('App/Sessions/session.config.php') + ->assertSuccess(); + + $this->installer + ->assertFileExists('App/Sessions/session.config.php') + ->assertFileContains('App/Sessions/session.config.php', 'FileSessionConfig'); + } + + #[Test] + public function installs_redis_session_config(): void + { + $this->console + ->call('install sessions') + ->confirm() + ->input(2) + ->input('App/Sessions/session.config.php') + ->assertSuccess(); + + $this->installer + ->assertFileExists('App/Sessions/session.config.php') + ->assertFileContains('App/Sessions/session.config.php', 'RedisSessionConfig') + ->assertFileContains('App/Sessions/session.config.php', 'expiration: Duration::days(30),'); + + $this->installer + ->assertFileContains('App/Sessions/session.config.php', 'return new RedisSessionConfig(') + ->assertFileNotContains('App/Sessions/session.config.php', 'cleanupStrategy:'); + + $this->assertFileDoesNotExist($this->installer->path('App/Sessions/CleanupSessionsCommand.php')); + } + + #[Test] + public function publishes_cleanup_command_when_requested(): void + { + $this->console + ->call('install sessions') + ->confirm() + ->input(0) + ->input(2) + ->input('App/Sessions/session.config.php') + ->confirm() + ->input('App/Sessions/CleanupSessionsCommand.php') + ->assertSuccess(); + + $this->installer->assertFileExists('App/Sessions/CleanupSessionsCommand.php'); + } + + #[Test] + public function writes_selected_cleanup_strategy_to_the_config(): void + { + $this->console + ->call('install sessions') + ->confirm() + ->input(0) + ->input(1) + ->input('App/Sessions/session.config.php') + ->assertSuccess(); + + $this->installer->assertFileContains('App/Sessions/session.config.php', 'cleanupStrategy: CleanupStrategy::EVERY_REQUEST'); + } +}