Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# Top level directories
/log/
/bench/
/package-lock.json

# Top-level+Sub directories
Expand Down
4 changes: 3 additions & 1 deletion docs/1-essentials/01-routing.md
Original file line number Diff line number Diff line change
Expand Up @@ -979,6 +979,8 @@ return new CookieConfig(

Sessions in Tempest are managed by the {b`Tempest\Http\Session\Session`} class. It can be injected anywhere needed. As soon as the {b`Tempest\Http\Session\Session`} is injected, it is started behind the scenes.

New sessions are only persisted once something is stored in them. This way, clients that don't keep cookies, such as API consumers or crawlers, don't fill up the session storage. Likewise, sessions that didn't change during a request aren't written again; only their activity is tracked.

```php
use Tempest\Http\Session\Session;

Expand Down Expand Up @@ -1062,7 +1064,7 @@ 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.

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).
By default, Tempest removes expired sessions at the end of a random request, in a deferred task. To keep the cost of cleaning up from growing with traffic, this happens at most once per minute. 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;
Expand Down
2 changes: 1 addition & 1 deletion packages/http/src/Session/CleanupStrategy.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ enum CleanupStrategy
case EVERY_REQUEST;

/**
* Expired sessions will be cleaned up after a random request. This is the default strategy, and provides a good balance between performance and cleanup frequency.
* Expired sessions will be cleaned up after a random request, at most once per minute. This is the default strategy, and provides a good balance between performance and cleanup frequency.
*/
case RANDOM_REQUESTS;

Expand Down
92 changes: 89 additions & 3 deletions packages/http/src/Session/ManageSessionMiddleware.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,34 @@

namespace Tempest\Http\Session;

use Tempest\Clock\Clock;
use Tempest\Core\DeferredTasks;
use Tempest\Http\Request;
use Tempest\Http\Response;
use Tempest\Router\HttpMiddleware;
use Tempest\Router\HttpMiddlewareCallable;
use Tempest\Router\SecFetchMode;
use Tempest\Support\Priority;

use function Tempest\internal_storage_path;

/**
* This middleware is responsible for creating the session and saving it on response.
*/
#[Priority(Priority::FRAMEWORK - 20)]
final readonly class ManageSessionMiddleware implements HttpMiddleware
{
/**
* Minimum amount of seconds between two cleanups when using the `RANDOM_REQUESTS` strategy.
*/
private const int RANDOM_REQUESTS_CLEANUP_INTERVAL = 60;

public function __construct(
private SessionManager $sessionManager,
private Session $session,
private SessionConfig $config,
private DeferredTasks $deferredTasks,
private Clock $clock,
) {}

public function __invoke(Request $request, HttpMiddlewareCallable $next): Response
Expand All @@ -32,7 +42,9 @@ public function __invoke(Request $request, HttpMiddlewareCallable $next): Respon
try {
return $next($request);
} finally {
$this->sessionManager->save($this->session);
if ($this->shouldPersist($request)) {
$this->sessionManager->save($this->session);
}

match ($this->config->cleanupStrategy) {
CleanupStrategy::EVERY_REQUEST => $this->scheduleSessionCleanup(),
Expand All @@ -42,6 +54,37 @@ public function __invoke(Request $request, HttpMiddlewareCallable $next): Respon
}
}

/**
* Clients that don't send a session cookie, like API consumers, crawlers or load testing tools, get a new session on every request.
* Those sessions are only persisted once they contain something worth keeping, so that they don't pile up in storage.
*/
private function shouldPersist(Request $request): bool
{
if (! $this->session->isNew) {
return true;
}

$data = $this->session->all();

// The previous URL is tracked on every request, so it alone doesn't warrant persisting a new session.
if (! $this->needsPreviousUrl($request)) {
unset($data[PreviousUrl::PREVIOUS_URL_SESSION_KEY]);
}

return $data !== [];
}

/**
* Browsers navigating between pages need the previous URL to be kept, because `Back` can't rely on the referer:
* the request that redirects back may not send one, for example because of a `no-referrer` policy.
* Other clients, like crawlers and API consumers, don't send `Sec-Fetch-*` headers, and can't submit forms
* that redirect back either, since `PreventCrossSiteRequestsMiddleware` requires them.
*/
private function needsPreviousUrl(Request $request): bool
{
return SecFetchMode::tryFrom($request->headers->get('sec-fetch-mode', default: '')) === SecFetchMode::NAVIGATE;
}

private function scheduleSessionCleanup(): void
{
$this->deferredTasks->add(
Expand All @@ -52,8 +95,51 @@ private function scheduleSessionCleanup(): void

private function maybeScheduleSessionCleanup(): void
{
if (random_int(min: 1, max: 50) === 1) {
$this->scheduleSessionCleanup();
if (random_int(min: 1, max: 50) !== 1) {
return;
}

if (! $this->claimSessionCleanup()) {
return;
}

$this->scheduleSessionCleanup();
}

/**
* Cleaning up has to go over all sessions, so it is throttled to keep its cost from growing along with the amount of sessions and traffic.
* The last cleanup time is read, checked and updated while holding a lock, so that concurrent requests can't both claim the same cleanup.
*/
private function claimSessionCleanup(): bool
{
$handle = @fopen(internal_storage_path('session-cleanup'), mode: 'c+');

if ($handle === false) {
return false;
}

try {
// Another request holding the lock is already claiming the cleanup, so there's no need to wait for it.
if (! flock($handle, LOCK_EX | LOCK_NB)) {
return false;
}

$now = $this->clock->now()->getTimestamp()->getSeconds();
$lastCleanupAt = (int) stream_get_contents($handle);

if (($now - $lastCleanupAt) < self::RANDOM_REQUESTS_CLEANUP_INTERVAL) {
return false;
}

ftruncate($handle, size: 0);
rewind($handle);
fwrite($handle, (string) $now);
fflush($handle);

return true;
} finally {
flock($handle, LOCK_UN);
fclose($handle);
}
}
}
44 changes: 35 additions & 9 deletions packages/http/src/Session/Managers/DatabaseSessionManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ public function getOrCreate(SessionId $id): Session
id: $id,
createdAt: $now,
lastActiveAt: $now,
isNew: true,
);

event(new SessionCreated($session));
Expand Down Expand Up @@ -60,6 +61,20 @@ public function save(Session $session): void
)
->execute();

$session->markAsPersisted();

return;
}

// Unchanged sessions only need their activity to be tracked.
if (! $session->isDirty()) {
query(DatabaseSession::class)
->update(last_active_at: $session->lastActiveAt)
->where('id', (string) $session->id)
->execute();

$session->markAsPersisted();

return;
}

Expand All @@ -70,6 +85,8 @@ public function save(Session $session): void
)
->where('id', (string) $session->id)
->execute();

$session->markAsPersisted();
}

public function delete(Session $session): void
Expand All @@ -95,18 +112,27 @@ public function deleteExpiredSessions(): void
->now()
->minus($this->config->expiration);

$expiredSessions = query(DatabaseSession::class)
->select()
->where('last_active_at < ?', $expired->format(FormatPattern::SQL_DATE_TIME))
->all();
$expiredBefore = $expired->format(FormatPattern::SQL_DATE_TIME);

foreach ($expiredSessions as $expiredSession) {
$expiredIds = array_map(
static fn (DatabaseSession $session) => (string) $session->id,
query(DatabaseSession::class)
->delete()
->where('id', $expiredSession->id)
->execute();
->select('id')
->where('last_active_at < ?', $expiredBefore)
->all(),
);

if ($expiredIds === []) {
return;
}

query(DatabaseSession::class)
->delete()
->where('last_active_at < ?', $expiredBefore)
->execute();

event(new SessionDeleted(new SessionId((string) $expiredSession->id)));
foreach ($expiredIds as $expiredId) {
event(new SessionDeleted(new SessionId($expiredId)));
}
}

Expand Down
38 changes: 31 additions & 7 deletions packages/http/src/Session/Managers/FileSessionManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace Tempest\Http\Session\Managers;

use Tempest\Clock\Clock;
use Tempest\DateTime\DateTime;
use Tempest\Http\Session\Config\FileSessionConfig;
use Tempest\Http\Session\Session;
use Tempest\Http\Session\SessionCreated;
Expand Down Expand Up @@ -34,6 +35,7 @@ public function getOrCreate(SessionId $id): Session
id: $id,
createdAt: $now,
lastActiveAt: $now,
isNew: true,
);

event(new SessionCreated($session));
Expand All @@ -44,13 +46,23 @@ public function getOrCreate(SessionId $id): Session

public function save(Session $session): void
{
$session->lastActiveAt = $this->clock->now();
$now = $this->clock->now();
$path = $this->getPath($session->id);

Filesystem\write_file(
filename: $this->getPath($session->id),
content: serialize($session),
flags: LOCK_EX,
);
$session->lastActiveAt = $now;

// Unchanged sessions aren't rewritten, their activity is tracked through the file's modification time instead.
if ($session->isDirty() || ! Filesystem\is_file($path)) {
Filesystem\write_file(
filename: $path,
content: serialize($session),
flags: LOCK_EX,
);
}

touch($path, $now->getTimestamp()->getSeconds());

$session->markAsPersisted();
}

public function delete(Session $session): void
Expand Down Expand Up @@ -103,12 +115,24 @@ private function load(SessionId $id): ?Session
return null;
}

return unserialize(
$session = unserialize(
data: Filesystem\read_locked_file($path),
options: [
'allowed_classes' => true,
],
);

if (! $session instanceof Session) {
return null;
}

$lastModifiedAt = filemtime($path);

if ($lastModifiedAt !== false && $lastModifiedAt > $session->lastActiveAt->getTimestamp()->getSeconds()) {
$session->lastActiveAt = DateTime::fromTimestamp($lastModifiedAt);
}

return $session;
} catch (Throwable) {
return null;
}
Expand Down
3 changes: 3 additions & 0 deletions packages/http/src/Session/Managers/RedisSessionManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ public function getOrCreate(SessionId $id): Session
id: $id,
createdAt: $now,
lastActiveAt: $now,
isNew: true,
);

event(new SessionCreated($session));
Expand All @@ -52,6 +53,8 @@ public function save(Session $session): void
value: serialize($session),
expiration: $this->config->expiration,
);

$session->markAsPersisted();
}

public function delete(Session $session): void
Expand Down
2 changes: 1 addition & 1 deletion packages/http/src/Session/PreviousUrl.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
*/
final readonly class PreviousUrl
{
private const string PREVIOUS_URL_SESSION_KEY = '#previous_url';
public const string PREVIOUS_URL_SESSION_KEY = '#previous_url';

private const string INTENDED_URL_SESSION_KEY = '#intended_url';

Expand Down
Loading
Loading