Skip to content

Commit c7a3ef7

Browse files
authored
[7.x] POC for same-session ID request concurrency limiting (#32636)
Implement opt-in session locking.
1 parent b78880a commit c7a3ef7

File tree

7 files changed

+186
-7
lines changed

7 files changed

+186
-7
lines changed

src/Illuminate/Cache/Lock.php

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,13 @@ abstract class Lock implements LockContract
3232
*/
3333
protected $owner;
3434

35+
/**
36+
* The number of milliseconds to wait before re-attempting to acquire a lock while blocking.
37+
*
38+
* @var int
39+
*/
40+
protected $sleepMilliseconds = 250;
41+
3542
/**
3643
* Create a new lock instance.
3744
*
@@ -107,7 +114,7 @@ public function block($seconds, $callback = null)
107114
$starting = $this->currentTime();
108115

109116
while (! $this->acquire()) {
110-
usleep(250 * 1000);
117+
usleep($this->sleepMilliseconds * 1000);
111118

112119
if ($this->currentTime() - $seconds >= $starting) {
113120
throw new LockTimeoutException;
@@ -144,4 +151,17 @@ protected function isOwnedByCurrentProcess()
144151
{
145152
return $this->getCurrentOwner() === $this->owner;
146153
}
154+
155+
/**
156+
* Specify the number of milliseconds to sleep in between blocked lock aquisition attempts.
157+
*
158+
* @param int $milliseconds
159+
* @return $this
160+
*/
161+
public function betweenBlockedAttemptsSleepFor($milliseconds)
162+
{
163+
$this->sleepMilliseconds = $milliseconds;
164+
165+
return $this;
166+
}
147167
}

src/Illuminate/Routing/AbstractRouteCollection.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,8 @@ public function compile()
144144
'defaults' => $route->defaults,
145145
'wheres' => $route->wheres,
146146
'bindingFields' => $route->bindingFields(),
147+
'lockSeconds' => $route->locksFor(),
148+
'waitSeconds' => $route->waitsFor(),
147149
];
148150
}
149151

src/Illuminate/Routing/CompiledRouteCollection.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -297,7 +297,8 @@ protected function newRoute(array $attributes)
297297
->setFallback($attributes['fallback'])
298298
->setDefaults($attributes['defaults'])
299299
->setWheres($attributes['wheres'])
300-
->setBindingFields($attributes['bindingFields']);
300+
->setBindingFields($attributes['bindingFields'])
301+
->block($attributes['lockSeconds'] ?? null, $attributes['waitSeconds'] ?? null);
301302
}
302303

303304
/**

src/Illuminate/Routing/Route.php

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,20 @@ class Route
9292
*/
9393
protected $originalParameters;
9494

95+
/**
96+
* Indicates the maximum number of seconds the route should acquire a session lock for.
97+
*
98+
* @var int|null
99+
*/
100+
protected $lockSeconds;
101+
102+
/**
103+
* Indicates the maximum number of seconds the route should wait while attempting to acquire a session lock.
104+
*
105+
* @var int|null
106+
*/
107+
protected $waitSeconds;
108+
95109
/**
96110
* The computed gathered middleware.
97111
*
@@ -972,6 +986,51 @@ public function excludedMiddleware()
972986
return (array) ($this->action['excluded_middleware'] ?? []);
973987
}
974988

989+
/**
990+
* Specify that the route should not allow concurrent requests from the same session.
991+
*
992+
* @param int|null $lockSeconds
993+
* @param int|null $waitSeconds
994+
* @return $this
995+
*/
996+
public function block($lockSeconds = 10, $waitSeconds = 10)
997+
{
998+
$this->lockSeconds = $lockSeconds;
999+
$this->waitSeconds = $waitSeconds;
1000+
1001+
return $this;
1002+
}
1003+
1004+
/**
1005+
* Specify that the route should allow concurrent requests from the same session.
1006+
*
1007+
* @return $this
1008+
*/
1009+
public function withoutBlocking()
1010+
{
1011+
return $this->block(null, null);
1012+
}
1013+
1014+
/**
1015+
* Get the maximum number of seconds the route's session lock should be held for.
1016+
*
1017+
* @return int|null
1018+
*/
1019+
public function locksFor()
1020+
{
1021+
return $this->lockSeconds;
1022+
}
1023+
1024+
/**
1025+
* Get the maximum number of seconds to wait while attempting to acquire a session lock.
1026+
*
1027+
* @return int|null
1028+
*/
1029+
public function waitsFor()
1030+
{
1031+
return $this->waitSeconds;
1032+
}
1033+
9751034
/**
9761035
* Get the dispatcher for the route's controller.
9771036
*

src/Illuminate/Session/Middleware/StartSession.php

Lines changed: 76 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,15 +20,24 @@ class StartSession
2020
*/
2121
protected $manager;
2222

23+
/**
24+
* The callback that can resolve an instance of the cache factory.
25+
*
26+
* @var callable
27+
*/
28+
protected $cacheFactoryResolver;
29+
2330
/**
2431
* Create a new session middleware.
2532
*
2633
* @param \Illuminate\Session\SessionManager $manager
34+
* @param callable $cacheFactoryResolver
2735
* @return void
2836
*/
29-
public function __construct(SessionManager $manager)
37+
public function __construct(SessionManager $manager, callable $cacheFactoryResolver = null)
3038
{
3139
$this->manager = $manager;
40+
$this->cacheFactoryResolver = $cacheFactoryResolver;
3241
}
3342

3443
/**
@@ -44,11 +53,62 @@ public function handle($request, Closure $next)
4453
return $next($request);
4554
}
4655

56+
$session = $this->getSession($request);
57+
58+
if ($this->manager->shouldBlock() ||
59+
($request->route() && $request->route()->locksFor())) {
60+
return $this->handleRequestWhileBlocking($request, $session, $next);
61+
} else {
62+
return $this->handleStatefulRequest($request, $session, $next);
63+
}
64+
}
65+
66+
/**
67+
* Handle the given request within session state.
68+
*
69+
* @param \Illuminate\Http\Request $request
70+
* @param \Illuminate\Contracts\Session\Session $session
71+
* @param \Closure $next
72+
* @return mixed
73+
*/
74+
protected function handleRequestWhileBlocking(Request $request, $session, Closure $next)
75+
{
76+
$lockFor = $request->route() && $request->route()->locksFor()
77+
? $request->route()->locksFor()
78+
: 10;
79+
80+
$lock = $this->cache($this->manager->blockDriver())
81+
->lock('session:'.$session->getId(), $lockFor)
82+
->betweenBlockedAttemptsSleepFor(50);
83+
84+
try {
85+
$lock->block(
86+
! is_null($request->route()->waitsFor())
87+
? $request->route()->waitsFor()
88+
: 10
89+
);
90+
91+
return $this->handleStatefulRequest($request, $session, $next);
92+
} finally {
93+
optional($lock)->release();
94+
}
95+
}
96+
97+
/**
98+
* Handle the given request within session state.
99+
*
100+
* @param \Illuminate\Http\Request $request
101+
* @param \Illuminate\Contracts\Session\Session $session
102+
* @param \Closure $next
103+
* @return mixed
104+
*/
105+
protected function handleStatefulRequest(Request $request, $session, Closure $next)
106+
{
47107
// If a session driver has been configured, we will need to start the session here
48108
// so that the data is ready for an application. Note that the Laravel sessions
49109
// do not make use of PHP "native" sessions in any way since they are crappy.
50110
$request->setLaravelSession(
51-
$session = $this->startSession($request)
111+
$this->startSession($request, $session)
52112
);
53113

54114
$this->collectGarbage($session);
@@ -71,11 +131,12 @@ public function handle($request, Closure $next)
71131
* Start the session for the given request.
72132
*
73133
* @param \Illuminate\Http\Request $request
134+
* @param \Illuminate\Contracts\Session\Session $session
74135
* @return \Illuminate\Contracts\Session\Session
75136
*/
76-
protected function startSession(Request $request)
137+
protected function startSession(Request $request, $session)
77138
{
78-
return tap($this->getSession($request), function ($session) use ($request) {
139+
return tap($session, function ($session) use ($request) {
79140
$session->setRequestOnHandler($request);
80141

81142
$session->start();
@@ -216,4 +277,15 @@ protected function sessionIsPersistent(array $config = null)
216277

217278
return ! is_null($config['driver'] ?? null);
218279
}
280+
281+
/**
282+
* Resolve the given cache driver.
283+
*
284+
* @param string $cache
285+
* @return \Illuminate\Cache\Store
286+
*/
287+
protected function cache($driver)
288+
{
289+
return call_user_func($this->cacheFactoryResolver)->driver($driver);
290+
}
219291
}

src/Illuminate/Session/SessionManager.php

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,26 @@ protected function buildEncryptedSession($handler)
202202
);
203203
}
204204

205+
/**
206+
* Determine if requests for the same session should wait for each to finish before executing.
207+
*
208+
* @return bool
209+
*/
210+
public function shouldBlock()
211+
{
212+
return $this->config->get('session.block', false);
213+
}
214+
215+
/**
216+
* Get the name of the cache store / driver that should be used to acquire session locks.
217+
*
218+
* @return string|null
219+
*/
220+
public function blockDriver()
221+
{
222+
return $this->config->get('session.block_store');
223+
}
224+
205225
/**
206226
* Get the session configuration.
207227
*

src/Illuminate/Session/SessionServiceProvider.php

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace Illuminate\Session;
44

5+
use Illuminate\Contracts\Cache\Factory as CacheFactory;
56
use Illuminate\Session\Middleware\StartSession;
67
use Illuminate\Support\ServiceProvider;
78

@@ -18,7 +19,11 @@ public function register()
1819

1920
$this->registerSessionDriver();
2021

21-
$this->app->singleton(StartSession::class);
22+
$this->app->singleton(StartSession::class, function () {
23+
return new StartSession($this->app->make(SessionManager::class), function () {
24+
return $this->app->make(CacheFactory::class);
25+
});
26+
});
2227
}
2328

2429
/**

0 commit comments

Comments
 (0)