From 69e2cfde58c36e7cfe081b81ea6ab14858510f21 Mon Sep 17 00:00:00 2001 From: Layla Tichy Date: Mon, 10 Nov 2025 12:45:05 +0000 Subject: [PATCH] feat(session): session cache and persistence --- .../Managers/DatabaseSessionManager.php | 117 ++++++---------- .../Session/Managers/FileSessionManager.php | 126 ++++++------------ packages/http/src/Session/Session.php | 45 ++++--- packages/http/src/Session/SessionCache.php | 104 +++++++++++++++ packages/http/src/Session/SessionManager.php | 10 +- .../src/Session/SessionResponseProcessor.php | 20 +++ 6 files changed, 233 insertions(+), 189 deletions(-) create mode 100644 packages/http/src/Session/SessionCache.php create mode 100644 packages/http/src/Session/SessionResponseProcessor.php diff --git a/packages/http/src/Session/Managers/DatabaseSessionManager.php b/packages/http/src/Session/Managers/DatabaseSessionManager.php index 5cefb00bfb..6317ccb956 100644 --- a/packages/http/src/Session/Managers/DatabaseSessionManager.php +++ b/packages/http/src/Session/Managers/DatabaseSessionManager.php @@ -7,12 +7,11 @@ use Tempest\Clock\Clock; use Tempest\Database\Database; use Tempest\Http\Session\Session; +use Tempest\Http\Session\SessionCache; use Tempest\Http\Session\SessionConfig; use Tempest\Http\Session\SessionDestroyed; use Tempest\Http\Session\SessionId; use Tempest\Http\Session\SessionManager; -use Tempest\Support\Arr; -use Tempest\Support\Arr\ArrayInterface; use function Tempest\Database\query; use function Tempest\event; @@ -23,128 +22,88 @@ public function __construct( private Clock $clock, private SessionConfig $config, private Database $database, + private SessionCache $cache, ) {} public function create(SessionId $id): Session { - return $this->persist($id); - } + $session = $this->resolve(id: $id); - public function set(SessionId $id, string $key, mixed $value): void - { - $this->persist($id, [...$this->getData($id), ...[$key => $value]]); - } - - public function get(SessionId $id, string $key, mixed $default = null): mixed - { - $value = Arr\get_by_key($this->getData($id), $key, $default); - - if ($value instanceof ArrayInterface) { - return $value->toArray(); + if ($session) { + return $session; } - return $value; - } - - public function all(SessionId $id): array - { - return $this->getData($id); - } + $session = new Session( + id: $id, + createdAt: $this->clock->now(), + lastActiveAt: $this->clock->now(), + data: [], + ); - public function remove(SessionId $id, string $key): void - { - $data = $this->getData($id); - $data = Arr\remove_keys($data, $key); + $this->cache->store(session: $session); - $this->persist($id, $data); + return $session; } public function destroy(SessionId $id): void { - query(DatabaseSession::class) + query(model: DatabaseSession::class) ->delete() ->where('session_id', (string) $id) ->execute(); - event(new SessionDestroyed($id)); - } - - public function isValid(SessionId $id): bool - { - $session = $this->resolve($id); - - if ($session === null) { - return false; - } - - return $this->clock->now()->before( - other: $session->lastActiveAt->plus($this->config->expiration), - ); + event(event: new SessionDestroyed(id: $id)); } public function cleanup(): void { $expired = $this->clock ->now() - ->minus($this->config->expiration); + ->minus(duration: $this->config->expiration); - query(DatabaseSession::class) + query(model: DatabaseSession::class) ->delete() - ->whereBefore('last_active_at', $expired) + ->whereBefore(field: 'last_active_at', date: $expired) ->execute(); } - private function resolve(SessionId $id): ?Session + public function resolve(SessionId $id): ?Session { - $session = query(DatabaseSession::class) + $session = $this->cache->find(sessionId: $id); + + if ($session) { + return $session; + } + + $session = query(model: DatabaseSession::class) ->select() ->where('session_id', (string) $id) ->first(); - if ($session === null) { + if (! $session) { return null; } - return new Session( + $session = new Session( id: $id, createdAt: $session->created_at, lastActiveAt: $session->last_active_at, - data: unserialize($session->data), + data: unserialize(data: $session->data), ); - } - /** - * @return array - */ - private function getData(SessionId $id): array - { - return $this->resolve($id)->data ?? []; + $this->cache->store(session: $session); + + return $session; } - /** - * @param array|null $data - */ - private function persist(SessionId $id, ?array $data = null): Session + public function persist(Session $session): void { - $now = $this->clock->now(); - $session = $this->resolve($id) ?? new Session( - id: $id, - createdAt: $now, - lastActiveAt: $now, - ); - - if ($data !== null) { - $session->data = $data; - } - - query(DatabaseSession::class)->updateOrCreate([ - 'session_id' => (string) $id, - ], [ - 'data' => serialize($session->data), + query(model: DatabaseSession::class)->updateOrCreate(find: [ + 'session_id' => (string) $session->id, + ], update: [ + 'data' => serialize(value: $session->data), 'created_at' => $session->createdAt, - 'last_active_at' => $now, + 'last_active_at' => $this->clock->now(), ]); - - return $session; } } diff --git a/packages/http/src/Session/Managers/FileSessionManager.php b/packages/http/src/Session/Managers/FileSessionManager.php index 5043085f0e..9610006e84 100644 --- a/packages/http/src/Session/Managers/FileSessionManager.php +++ b/packages/http/src/Session/Managers/FileSessionManager.php @@ -6,6 +6,7 @@ use Tempest\Clock\Clock; use Tempest\Http\Session\Session; +use Tempest\Http\Session\SessionCache; use Tempest\Http\Session\SessionConfig; use Tempest\Http\Session\SessionDestroyed; use Tempest\Http\Session\SessionId; @@ -21,138 +22,99 @@ public function __construct( private Clock $clock, private SessionConfig $sessionConfig, + private SessionCache $cache, ) {} public function create(SessionId $id): Session { - return $this->persist($id); - } - - public function set(SessionId $id, string $key, mixed $value): void - { - $this->persist($id, [...$this->getData($id), ...[$key => $value]]); - } + $session = $this->resolve(id:$id); - public function get(SessionId $id, string $key, mixed $default = null): mixed - { - return $this->getData($id)[$key] ?? $default; - } + if ($session) { + return $session; + } - public function remove(SessionId $id, string $key): void - { - $data = $this->getData($id); + $session = new Session( + id: $id, + createdAt: $this->clock->now(), + lastActiveAt: $this->clock->now(), + data: [], + ); - unset($data[$key]); + $this->cache->store(session:$session); - $this->persist($id, $data); + return $session; } public function destroy(SessionId $id): void { - unlink($this->getPath($id)); + unlink(filename:$this->getPath(id:$id)); - event(new SessionDestroyed($id)); + event(event:new SessionDestroyed(id:$id)); } - public function isValid(SessionId $id): bool + public function resolve(SessionId $id): ?Session { - $session = $this->resolve($id); + $session = $this->cache->find(sessionId:$id); - if ($session === null) { - return false; + if ($session) { + return $session; } - if (! ($session->lastActiveAt ?? null)) { - return false; - } - - return $this->clock->now()->before( - other: $session->lastActiveAt->plus($this->sessionConfig->expiration), - ); - } - - private function getPath(SessionId $id): string - { - return internal_storage_path($this->sessionConfig->path, (string) $id); - } - - private function resolve(SessionId $id): ?Session - { - $path = $this->getPath($id); + $path = $this->getPath(id:$id); try { - if (! Filesystem\is_file($path)) { + if (! Filesystem\is_file(path:$path)) { return null; } - $file_pointer = fopen($path, 'rb'); - flock($file_pointer, LOCK_SH); + $file_pointer = fopen(filename:$path, mode:'rb'); + flock(stream:$file_pointer, operation:LOCK_SH); + + $content = Filesystem\read_file(filename:$path); + + flock(stream:$file_pointer, operation:LOCK_UN); + fclose(stream:$file_pointer); - $content = Filesystem\read_file($path); + $session = unserialize(data:$content, options:['allowed_classes' => true]); - flock($file_pointer, LOCK_UN); - fclose($file_pointer); + $this->cache->store(session:$session); - return unserialize($content, ['allowed_classes' => true]); + return $session; } catch (Throwable) { return null; } } - public function all(SessionId $id): array + public function persist(Session $session): void { - return $this->getData($id); - } + $session->lastActiveAt = $this->clock->now(); - /** - * @return array - */ - private function getData(SessionId $id): array - { - return $this->resolve($id)->data ?? []; - } - - /** - * @param array|null $data - */ - private function persist(SessionId $id, ?array $data = null): Session - { - $now = $this->clock->now(); - $session = $this->resolve($id) ?? new Session( - id: $id, - createdAt: $now, - lastActiveAt: $now, - ); - - $session->lastActiveAt = $now; - - if ($data !== null) { - $session->data = $data; - } - - Filesystem\write_file($this->getPath($id), serialize($session), LOCK_EX); - - return $session; + Filesystem\write_file(filename:$this->getPath(id:$session->id), content:serialize(value:$session), flags:LOCK_EX); } public function cleanup(): void { - $sessionFiles = glob(internal_storage_path($this->sessionConfig->path, '/*')); + $sessionFiles = glob(pattern:internal_storage_path($this->sessionConfig->path, '/*')); foreach ($sessionFiles as $sessionFile) { - $id = new SessionId(pathinfo($sessionFile, PATHINFO_FILENAME)); + $id = new SessionId(id:pathinfo(path:$sessionFile, flags:PATHINFO_FILENAME)); - $session = $this->resolve($id); + $session = $this->resolve(id:$id); if ($session === null) { continue; } - if ($this->isValid($session->id)) { + if ($this->cache->isValid(session:$session)) { continue; } $session->destroy(); } } + + private function getPath(SessionId $id): string + { + return internal_storage_path($this->sessionConfig->path, (string) $id); + } } diff --git a/packages/http/src/Session/Session.php b/packages/http/src/Session/Session.php index fb275508df..f421f1e425 100644 --- a/packages/http/src/Session/Session.php +++ b/packages/http/src/Session/Session.php @@ -21,8 +21,8 @@ final class Session private array $expiredKeys = []; - private SessionManager $manager { - get => get(SessionManager::class); + private SessionCache $cache { + get => get(className:SessionCache::class); } /** @@ -30,11 +30,11 @@ final class Session */ public string $token { get { - if (! $this->get(self::CSRF_TOKEN_KEY)) { - $this->set(self::CSRF_TOKEN_KEY, Random\uuid()); + if (! $this->get(key:self::CSRF_TOKEN_KEY)) { + $this->set(key:self::CSRF_TOKEN_KEY, value:Random\uuid()); } - return $this->get(self::CSRF_TOKEN_KEY); + return $this->get(key:self::CSRF_TOKEN_KEY); } } @@ -48,7 +48,7 @@ public function __construct( public function set(string $key, mixed $value): void { - $this->manager->set($this->id, $key, $value); + $this->cache->set(sessionId:$this->id, key:$key, value:$value); } /** @@ -56,7 +56,7 @@ public function set(string $key, mixed $value): void */ public function flash(string $key, mixed $value): void { - $this->manager->set($this->id, $key, new FlashValue($value)); + $this->cache->set(sessionId:$this->id, key:$key, value:new FlashValue(value:$value)); } /** @@ -64,7 +64,7 @@ public function flash(string $key, mixed $value): void */ public function reflash(): void { - foreach ($this->manager->all($this->id) as $key => $value) { + foreach ($this->cache->all(sessionId:$this->id) as $key => $value) { if (! $value instanceof FlashValue) { continue; } @@ -75,7 +75,7 @@ public function reflash(): void public function get(string $key, mixed $default = null): mixed { - $value = $this->manager->get($this->id, $key, $default); + $value = $this->cache->get(sessionId:$this->id, key:$key, default:$default); if ($value instanceof FlashValue) { $this->expiredKeys[$key] = $key; @@ -88,22 +88,22 @@ public function get(string $key, mixed $default = null): mixed /** @return \Tempest\Validation\Rule[] */ public function getErrorsFor(string $name): array { - return $this->get(self::VALIDATION_ERRORS)[$name] ?? []; + return $this->get(key:self::VALIDATION_ERRORS)[$name] ?? []; } public function getOriginalValueFor(string $name, mixed $default = ''): mixed { - return $this->get(self::ORIGINAL_VALUES)[$name] ?? $default; + return $this->get(key:self::ORIGINAL_VALUES)[$name] ?? $default; } public function getPreviousUrl(): string { - return $this->get(self::PREVIOUS_URL, default: ''); + return $this->get(key:self::PREVIOUS_URL, default: ''); } public function setPreviousUrl(string $url): void { - $this->set(self::PREVIOUS_URL, $url); + $this->set(key:self::PREVIOUS_URL, value:$url); } /** @@ -111,37 +111,42 @@ public function setPreviousUrl(string $url): void */ public function consume(string $key, mixed $default = null): mixed { - $value = $this->get($key, $default); + $value = $this->get(key:$key, default:$default); - $this->remove($key); + $this->remove(key:$key); return $value; } public function all(): array { - return $this->manager->all($this->id); + return $this->cache->all(sessionId:$this->id); } public function remove(string $key): void { - $this->manager->remove($this->id, $key); + $this->cache->remove(sessionId:$this->id, key:$key); } public function destroy(): void { - $this->manager->destroy($this->id); + $this->cache->destroy(sessionId:$this->id); } public function isValid(): bool { - return $this->manager->isValid($this->id); + return $this->cache->isValid(session:$this->id); + } + + public function persist(): void + { + $this->cache->persist(sessionId:$this->id); } public function cleanup(): void { foreach ($this->expiredKeys as $key) { - $this->manager->remove($this->id, $key); + $this->cache->remove(sessionId:$this->id, key:$key); } } } diff --git a/packages/http/src/Session/SessionCache.php b/packages/http/src/Session/SessionCache.php new file mode 100644 index 0000000000..133c0604ee --- /dev/null +++ b/packages/http/src/Session/SessionCache.php @@ -0,0 +1,104 @@ + + */ + private array $sessions = []; + + /** + * @var array + */ + private array $originalValues = []; + + private SessionManager $manager { + get => get(className: SessionManager::class); + } + + public function __construct( + private readonly Clock $clock, + private readonly SessionConfig $config, + ) {} + + public function store(Session $session): void + { + $this->sessions[(string) $session->id] = $session; + $this->originalValues[(string) $session->id] = clone $session; + } + + public function find(SessionId $sessionId): ?Session + { + return $this->sessions[(string) $sessionId] ?? null; + } + + public function set(SessionId $sessionId, string $key, mixed $value): void + { + $this->sessions[(string) $sessionId]->data[$key] = $value; + } + + public function get(SessionId $sessionId, string $key, mixed $default = null): mixed + { + return $this->sessions[(string) $sessionId]->data[$key] ?? $default; + } + + public function all(SessionId $sessionId): array + { + return $this->sessions[(string) $sessionId]->data; + } + + public function remove(SessionId $sessionId, string $key): void + { + unset($this->sessions[(string) $sessionId]->data[$key]); + } + + public function destroy(SessionId $sessionId): void + { + unset($this->sessions[(string) $sessionId]); + unset($this->originalValues[(string) $sessionId]); + + $this->manager->destroy(id: $sessionId); + } + + public function persist(SessionId $sessionId): void + { + if (! $this->hasChanged(sessionId: $sessionId)) { + return; + } + + $session = $this->sessions[(string) $sessionId]; + + $this->manager->persist(session: $session); + + $this->originalValues[(string) $sessionId] = clone $session; + } + + public function isValid(Session $session): bool + { + return $this->clock->now()->before( + other: $session->lastActiveAt->plus(duration: $this->config->expiration), + ); + } + + private function hasChanged(SessionId $sessionId): bool + { + $current = $this->sessions[(string) $sessionId]; + $original = $this->originalValues[(string) $sessionId] ?? null; + + if ($original === null) { + return true; + } + + return json_encode(value: $current->data) !== json_encode(value: $original->data); + } +} diff --git a/packages/http/src/Session/SessionManager.php b/packages/http/src/Session/SessionManager.php index 577eaafc46..de06502683 100644 --- a/packages/http/src/Session/SessionManager.php +++ b/packages/http/src/Session/SessionManager.php @@ -8,17 +8,11 @@ interface SessionManager { public function create(SessionId $id): Session; - public function set(SessionId $id, string $key, mixed $value): void; - - public function get(SessionId $id, string $key, mixed $default = null): mixed; - - public function all(SessionId $id): array; - - public function remove(SessionId $id, string $key): void; + public function resolve(SessionId $id): ?Session; public function destroy(SessionId $id): void; - public function isValid(SessionId $id): bool; + public function persist(Session $session): void; public function cleanup(): void; } diff --git a/packages/http/src/Session/SessionResponseProcessor.php b/packages/http/src/Session/SessionResponseProcessor.php new file mode 100644 index 0000000000..48cb6becf6 --- /dev/null +++ b/packages/http/src/Session/SessionResponseProcessor.php @@ -0,0 +1,20 @@ +session->persist(); + + return $response; + } +}