Skip to content

Commit ed1b68f

Browse files
authored
perf(auth): memoize current authenticatable (#2144)
1 parent 7aa7241 commit ed1b68f

2 files changed

Lines changed: 258 additions & 7 deletions

File tree

packages/auth/src/Authentication/SessionAuthenticator.php

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,38 +4,53 @@
44

55
namespace Tempest\Auth\Authentication;
66

7+
use Tempest\Container\Resettable;
78
use Tempest\Http\Session\Session;
89
use Tempest\Http\Session\SessionManager;
910

10-
final readonly class SessionAuthenticator implements Authenticator
11+
final class SessionAuthenticator implements Authenticator, Resettable
1112
{
1213
public const string AUTHENTICATABLE_KEY = '#authenticatable:id';
1314

1415
public const string AUTHENTICATABLE_CLASS = '#authenticatable:class';
1516

17+
private int|string|null $currentId = null;
18+
19+
private ?string $currentClass = null;
20+
21+
private ?Authenticatable $current = null;
22+
1623
public function __construct(
17-
private SessionManager $sessionManager,
18-
private Session $session,
19-
private AuthenticatableResolver $authenticatableResolver,
24+
private readonly SessionManager $sessionManager,
25+
private readonly Session $session,
26+
private readonly AuthenticatableResolver $authenticatableResolver,
2027
) {}
2128

2229
public function authenticate(Authenticatable $authenticatable): void
2330
{
31+
$id = $this->authenticatableResolver->resolveId($authenticatable);
32+
$class = $authenticatable::class;
33+
2434
$this->session->set(
2535
key: self::AUTHENTICATABLE_CLASS,
26-
value: $authenticatable::class,
36+
value: $class,
2737
);
2838

2939
$this->session->set(
3040
key: self::AUTHENTICATABLE_KEY,
31-
value: $this->authenticatableResolver->resolveId($authenticatable),
41+
value: $id,
3242
);
43+
44+
$this->currentId = $id;
45+
$this->currentClass = $class;
46+
$this->current = $authenticatable;
3347
}
3448

3549
public function deauthenticate(): void
3650
{
3751
$this->session->remove(self::AUTHENTICATABLE_KEY);
3852
$this->session->remove(self::AUTHENTICATABLE_CLASS);
53+
$this->clearCurrent();
3954

4055
$this->sessionManager->save($this->session);
4156
}
@@ -46,9 +61,31 @@ public function current(): ?Authenticatable
4661
$class = $this->session->get(self::AUTHENTICATABLE_CLASS);
4762

4863
if (! $id || ! $class) {
64+
$this->clearCurrent();
65+
4966
return null;
5067
}
5168

52-
return $this->authenticatableResolver->resolve($id, $class);
69+
if ($this->currentId === $id && $this->currentClass === $class) {
70+
return $this->current;
71+
}
72+
73+
$this->currentId = $id;
74+
$this->currentClass = $class;
75+
$this->current = $this->authenticatableResolver->resolve($id, $class);
76+
77+
return $this->current;
78+
}
79+
80+
public function reset(): void
81+
{
82+
$this->clearCurrent();
83+
}
84+
85+
private function clearCurrent(): void
86+
{
87+
$this->currentId = null;
88+
$this->currentClass = null;
89+
$this->current = null;
5390
}
5491
}
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\Auth\Tests;
6+
7+
use PHPUnit\Framework\Attributes\Test;
8+
use PHPUnit\Framework\TestCase;
9+
use Tempest\Auth\Authentication\Authenticatable;
10+
use Tempest\Auth\Authentication\AuthenticatableResolver;
11+
use Tempest\Auth\Authentication\SessionAuthenticator;
12+
use Tempest\Container\Resettable;
13+
use Tempest\DateTime\DateTime;
14+
use Tempest\Http\Session\Session;
15+
use Tempest\Http\Session\SessionId;
16+
use Tempest\Http\Session\SessionManager;
17+
18+
final class SessionAuthenticatorTest extends TestCase
19+
{
20+
#[Test]
21+
public function current_memoizes_the_resolved_authenticatable_for_the_current_session_identity(): void
22+
{
23+
$authenticatable = new MemoizedAuthenticatable(id: 1);
24+
$resolver = new CountingAuthenticatableResolver($authenticatable);
25+
$session = $this->createSession();
26+
$session->set(SessionAuthenticator::AUTHENTICATABLE_KEY, 1);
27+
$session->set(SessionAuthenticator::AUTHENTICATABLE_CLASS, MemoizedAuthenticatable::class);
28+
29+
$authenticator = new SessionAuthenticator(
30+
sessionManager: new TestingSessionManager(),
31+
session: $session,
32+
authenticatableResolver: $resolver,
33+
);
34+
35+
$this->assertSame($authenticatable, $authenticator->current());
36+
$this->assertSame($authenticatable, $authenticator->current());
37+
$this->assertSame(1, $resolver->resolveCalls);
38+
}
39+
40+
#[Test]
41+
public function current_memoizes_a_missing_authenticatable_for_the_current_session_identity(): void
42+
{
43+
$resolver = new CountingAuthenticatableResolver();
44+
$session = $this->createSession();
45+
$session->set(SessionAuthenticator::AUTHENTICATABLE_KEY, 1);
46+
$session->set(SessionAuthenticator::AUTHENTICATABLE_CLASS, MemoizedAuthenticatable::class);
47+
48+
$authenticator = new SessionAuthenticator(
49+
sessionManager: new TestingSessionManager(),
50+
session: $session,
51+
authenticatableResolver: $resolver,
52+
);
53+
54+
$this->assertNull($authenticator->current());
55+
$this->assertNull($authenticator->current());
56+
$this->assertSame(1, $resolver->resolveCalls);
57+
}
58+
59+
#[Test]
60+
public function current_re_resolves_when_the_session_identity_changes(): void
61+
{
62+
$resolver = new CountingAuthenticatableResolver(
63+
new MemoizedAuthenticatable(id: 1),
64+
new MemoizedAuthenticatable(id: 2),
65+
);
66+
$session = $this->createSession();
67+
$session->set(SessionAuthenticator::AUTHENTICATABLE_KEY, 1);
68+
$session->set(SessionAuthenticator::AUTHENTICATABLE_CLASS, MemoizedAuthenticatable::class);
69+
70+
$authenticator = new SessionAuthenticator(
71+
sessionManager: new TestingSessionManager(),
72+
session: $session,
73+
authenticatableResolver: $resolver,
74+
);
75+
76+
$current = $authenticator->current();
77+
$this->assertInstanceOf(MemoizedAuthenticatable::class, $current);
78+
$this->assertSame(1, $current->id);
79+
80+
$session->set(SessionAuthenticator::AUTHENTICATABLE_KEY, 2);
81+
82+
$current = $authenticator->current();
83+
$this->assertInstanceOf(MemoizedAuthenticatable::class, $current);
84+
$this->assertSame(2, $current->id);
85+
$this->assertSame(2, $resolver->resolveCalls);
86+
}
87+
88+
#[Test]
89+
public function reset_clears_the_cached_current_authenticatable(): void
90+
{
91+
$authenticatable = new MemoizedAuthenticatable(id: 1);
92+
$resolver = new CountingAuthenticatableResolver($authenticatable);
93+
$session = $this->createSession();
94+
$session->set(SessionAuthenticator::AUTHENTICATABLE_KEY, 1);
95+
$session->set(SessionAuthenticator::AUTHENTICATABLE_CLASS, MemoizedAuthenticatable::class);
96+
97+
$authenticator = new SessionAuthenticator(
98+
sessionManager: new TestingSessionManager(),
99+
session: $session,
100+
authenticatableResolver: $resolver,
101+
);
102+
103+
$this->assertInstanceOf(Resettable::class, $authenticator);
104+
$this->assertSame($authenticatable, $authenticator->current());
105+
106+
$authenticator->reset();
107+
108+
$this->assertSame($authenticatable, $authenticator->current());
109+
$this->assertSame(2, $resolver->resolveCalls);
110+
}
111+
112+
#[Test]
113+
public function authenticate_replaces_a_cached_current_authenticatable(): void
114+
{
115+
$resolver = new CountingAuthenticatableResolver(
116+
new MemoizedAuthenticatable(id: 1),
117+
new MemoizedAuthenticatable(id: 2),
118+
);
119+
$session = $this->createSession();
120+
$session->set(SessionAuthenticator::AUTHENTICATABLE_KEY, 1);
121+
$session->set(SessionAuthenticator::AUTHENTICATABLE_CLASS, MemoizedAuthenticatable::class);
122+
123+
$authenticator = new SessionAuthenticator(
124+
sessionManager: new TestingSessionManager(),
125+
session: $session,
126+
authenticatableResolver: $resolver,
127+
);
128+
129+
$current = $authenticator->current();
130+
$this->assertInstanceOf(MemoizedAuthenticatable::class, $current);
131+
$this->assertSame(1, $current->id);
132+
133+
$authenticator->authenticate(new MemoizedAuthenticatable(id: 2));
134+
135+
$current = $authenticator->current();
136+
$this->assertInstanceOf(MemoizedAuthenticatable::class, $current);
137+
$this->assertSame(2, $current->id);
138+
}
139+
140+
private function createSession(): Session
141+
{
142+
$now = DateTime::now();
143+
144+
return new Session(
145+
id: new SessionId('test-session'),
146+
createdAt: $now,
147+
lastActiveAt: $now,
148+
);
149+
}
150+
}
151+
152+
final readonly class MemoizedAuthenticatable implements Authenticatable
153+
{
154+
public function __construct(
155+
public int $id,
156+
) {}
157+
}
158+
159+
final class CountingAuthenticatableResolver implements AuthenticatableResolver
160+
{
161+
public int $resolveCalls = 0;
162+
163+
/** @var array<int|string, MemoizedAuthenticatable> */
164+
private array $authenticatables = [];
165+
166+
public function __construct(MemoizedAuthenticatable ...$authenticatables)
167+
{
168+
foreach ($authenticatables as $authenticatable) {
169+
$this->authenticatables[$authenticatable->id] = $authenticatable;
170+
}
171+
}
172+
173+
public function resolve(int|string $id, string $class): ?Authenticatable
174+
{
175+
$this->resolveCalls++;
176+
177+
if ($class !== MemoizedAuthenticatable::class) {
178+
return null;
179+
}
180+
181+
return $this->authenticatables[$id] ?? null;
182+
}
183+
184+
public function resolveId(Authenticatable $authenticatable): int
185+
{
186+
return $authenticatable instanceof MemoizedAuthenticatable ? $authenticatable->id : 0;
187+
}
188+
}
189+
190+
final class TestingSessionManager implements SessionManager
191+
{
192+
public int $savedSessions = 0;
193+
194+
public function getOrCreate(SessionId $id): Session
195+
{
196+
$now = DateTime::now();
197+
198+
return new Session($id, $now, $now);
199+
}
200+
201+
public function save(Session $session): void
202+
{
203+
$this->savedSessions++;
204+
}
205+
206+
public function delete(Session $session): void {}
207+
208+
public function isValid(Session $session): bool
209+
{
210+
return true;
211+
}
212+
213+
public function deleteExpiredSessions(): void {}
214+
}

0 commit comments

Comments
 (0)