RI websocket performance
This commit is contained in:
parent
5b8a2a8112
commit
2849f0fe5f
|
|
@ -251,11 +251,6 @@ class StartServer extends Command
|
|||
$restartData = $this->getLastRestartData();
|
||||
$currentRestart = $restartData['time'] ?? null;
|
||||
|
||||
\Log::channel('websocket')->debug('Restart timer tick', [
|
||||
'last_restart' => $this->lastRestart,
|
||||
'current_restart' => $currentRestart,
|
||||
]);
|
||||
|
||||
// Only trigger restart if lastRestart was set and a new restart signal was received
|
||||
if ($this->lastRestart !== null && $currentRestart !== null && $currentRestart !== $this->lastRestart) {
|
||||
$this->restartSoftShutdown = $restartData['soft'] ?? false;
|
||||
|
|
@ -402,10 +397,88 @@ class StartServer extends Command
|
|||
$this->buildServer();
|
||||
\Log::channel('websocket')->debug('Server instance built, starting event loop...');
|
||||
|
||||
// Warmup: pre-heat hot code paths to trigger JIT compilation
|
||||
// This reduces first-request latency from ~15ms to ~3ms
|
||||
$this->warmupCodePaths();
|
||||
|
||||
$this->server->run();
|
||||
\Log::channel('websocket')->debug('Event loop stopped, server shutdown complete');
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-heat frequently used code paths to trigger JIT compilation
|
||||
* and load code into CPU cache before real requests arrive.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function warmupCodePaths(): void
|
||||
{
|
||||
// Warmup JSON encode/decode (used in every message)
|
||||
$testPayload = '{"event":"pusher.ping","data":{}}';
|
||||
for ($i = 0; $i < 10; $i++) {
|
||||
$decoded = json_decode($testPayload, true);
|
||||
json_encode($decoded);
|
||||
strpos($testPayload, 'ping');
|
||||
}
|
||||
|
||||
// Warmup time() function
|
||||
time();
|
||||
|
||||
// Warmup string operations
|
||||
$pong = '{"event":"pusher.pong"}';
|
||||
strlen($pong);
|
||||
|
||||
// Preload WebSocket controller classes to avoid autoloading latency on first request
|
||||
$this->preloadControllers();
|
||||
|
||||
// Warmup database connection (will be re-established in child, but PDO classes get loaded)
|
||||
try {
|
||||
\DB::connection()->getPdo();
|
||||
} catch (\Throwable $e) {
|
||||
// Ignore connection errors, we just want to load classes
|
||||
}
|
||||
|
||||
\Log::channel('websocket')->debug('Code paths warmed up (JIT pre-compiled)');
|
||||
}
|
||||
|
||||
/**
|
||||
* Preload all WebSocket controller classes to avoid autoloading latency
|
||||
* on first request. This triggers class loading and JIT compilation.
|
||||
*
|
||||
* Uses ControllerResolver which:
|
||||
* - Scans app/Websocket/Controllers/ recursively (including subfolders)
|
||||
* - Caches discovered controllers in memory for O(1) lookup
|
||||
* - Supports folder-based controllers (e.g., Admin/UserController)
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function preloadControllers(): void
|
||||
{
|
||||
// Use ControllerResolver to scan and cache all controllers
|
||||
\BlaxSoftware\LaravelWebSockets\Websocket\ControllerResolver::preload();
|
||||
|
||||
$stats = \BlaxSoftware\LaravelWebSockets\Websocket\ControllerResolver::getStats();
|
||||
\Log::channel('websocket')->debug('Controllers preloaded', [
|
||||
'available' => $stats['available'],
|
||||
]);
|
||||
|
||||
// Preload commonly used classes
|
||||
$commonClasses = [
|
||||
\BlaxSoftware\LaravelWebSockets\Websocket\Controller::class,
|
||||
\BlaxSoftware\LaravelWebSockets\Websocket\ControllerResolver::class,
|
||||
\BlaxSoftware\LaravelWebSockets\Websocket\MockConnection::class,
|
||||
\BlaxSoftware\LaravelWebSockets\Websocket\MockConnectionSocketPair::class,
|
||||
\BlaxSoftware\LaravelWebSockets\Ipc\SocketPairIpc::class,
|
||||
\BlaxSoftware\LaravelWebSockets\Cache\IpcCache::class,
|
||||
];
|
||||
|
||||
foreach ($commonClasses as $class) {
|
||||
if (class_exists($class, true)) {
|
||||
new \ReflectionClass($class);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log comprehensive server startup information
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -70,17 +70,11 @@ class Controller
|
|||
}
|
||||
|
||||
try {
|
||||
$contr = (strpos($event[0], '-') >= 0)
|
||||
? implode('', array_map(fn($item) => ucfirst($item), explode('-', $event[0])))
|
||||
: ucfirst($event[0]);
|
||||
|
||||
$vendorcontroller = '\\BlaxSoftware\\LaravelWebSockets\\Websocket\\Controllers\\' . $contr . 'Controller';
|
||||
$appcontroller = '\\App\\Websocket\\Controllers\\' . $contr . 'Controller';
|
||||
$eventPrefix = $event[0];
|
||||
$method = static::without_uniquifyer($event[1]);
|
||||
|
||||
$controllerClass = class_exists($appcontroller)
|
||||
? $appcontroller
|
||||
: (class_exists($vendorcontroller) ? $vendorcontroller : null);
|
||||
// Use cached controller resolver for fast lookup
|
||||
$controllerClass = ControllerResolver::resolve($eventPrefix);
|
||||
|
||||
if (! $controllerClass) {
|
||||
return self::send_error($connection, $message, 'Event could not be associated');
|
||||
|
|
|
|||
|
|
@ -0,0 +1,287 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BlaxSoftware\LaravelWebSockets\Websocket;
|
||||
|
||||
/**
|
||||
* Resolves WebSocket controller classes with caching and fuzzy folder matching.
|
||||
*
|
||||
* Supports:
|
||||
* - Flat controllers: `app.method` → `AppController`
|
||||
* - Kebab-case: `admin-user.method` → `AdminUserController`
|
||||
* - Folder structure: `admin-user.method` → `Admin/UserController` (fuzzy)
|
||||
* - Dynamic discovery: new controllers are found and cached at runtime
|
||||
*/
|
||||
class ControllerResolver
|
||||
{
|
||||
/**
|
||||
* In-memory cache of event prefix → controller class mappings
|
||||
* This persists for the lifetime of the WebSocket server process
|
||||
*
|
||||
* @var array<string, string|null>
|
||||
*/
|
||||
private static array $controllerCache = [];
|
||||
|
||||
/**
|
||||
* Pre-scanned controller paths for fast lookup
|
||||
* Maps lowercase class name → actual class name with namespace
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
private static array $availableControllers = [];
|
||||
|
||||
/**
|
||||
* Whether controllers have been pre-scanned
|
||||
*/
|
||||
private static bool $scanned = false;
|
||||
|
||||
/**
|
||||
* App controller namespace
|
||||
*/
|
||||
private const APP_NAMESPACE = '\\App\\Websocket\\Controllers\\';
|
||||
|
||||
/**
|
||||
* Package controller namespace
|
||||
*/
|
||||
private const VENDOR_NAMESPACE = '\\BlaxSoftware\\LaravelWebSockets\\Websocket\\Controllers\\';
|
||||
|
||||
/**
|
||||
* Resolve controller class for an event prefix
|
||||
*
|
||||
* @param string $eventPrefix The event prefix (e.g., 'app', 'admin-user', 'admin-user-settings')
|
||||
* @return string|null The fully qualified controller class name, or null if not found
|
||||
*/
|
||||
public static function resolve(string $eventPrefix): ?string
|
||||
{
|
||||
// Check cache first (O(1) lookup)
|
||||
if (array_key_exists($eventPrefix, self::$controllerCache)) {
|
||||
return self::$controllerCache[$eventPrefix];
|
||||
}
|
||||
|
||||
// Ensure controllers are scanned
|
||||
if (!self::$scanned) {
|
||||
self::scanControllers();
|
||||
}
|
||||
|
||||
// Try to find the controller
|
||||
$controllerClass = self::findController($eventPrefix);
|
||||
|
||||
// Cache the result (even if null, to avoid repeated lookups)
|
||||
self::$controllerCache[$eventPrefix] = $controllerClass;
|
||||
|
||||
return $controllerClass;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find controller using multiple strategies
|
||||
*/
|
||||
private static function findController(string $eventPrefix): ?string
|
||||
{
|
||||
// Strategy 1: Direct match (e.g., 'app' → 'AppController')
|
||||
$directName = self::kebabToPascal($eventPrefix) . 'Controller';
|
||||
if ($class = self::findInAvailable($directName)) {
|
||||
return $class;
|
||||
}
|
||||
|
||||
// Strategy 2: Folder structure (e.g., 'admin-user' → 'Admin/UserController')
|
||||
$parts = explode('-', $eventPrefix);
|
||||
if (count($parts) > 1) {
|
||||
// Try progressively deeper folder structures
|
||||
// 'admin-user-settings' tries:
|
||||
// - Admin/User/SettingsController
|
||||
// - Admin/UserSettingsController
|
||||
// - AdminUser/SettingsController
|
||||
|
||||
for ($folderDepth = count($parts) - 1; $folderDepth >= 1; $folderDepth--) {
|
||||
$folderParts = array_slice($parts, 0, $folderDepth);
|
||||
$nameParts = array_slice($parts, $folderDepth);
|
||||
|
||||
$folder = implode('/', array_map('ucfirst', $folderParts));
|
||||
$name = implode('', array_map('ucfirst', $nameParts)) . 'Controller';
|
||||
|
||||
// Try app namespace with folder
|
||||
$appClass = self::APP_NAMESPACE . str_replace('/', '\\', $folder) . '\\' . $name;
|
||||
if (class_exists($appClass)) {
|
||||
self::$availableControllers[strtolower($name)] = $appClass;
|
||||
return $appClass;
|
||||
}
|
||||
|
||||
// Try vendor namespace with folder
|
||||
$vendorClass = self::VENDOR_NAMESPACE . str_replace('/', '\\', $folder) . '\\' . $name;
|
||||
if (class_exists($vendorClass)) {
|
||||
self::$availableControllers[strtolower($name)] = $vendorClass;
|
||||
return $vendorClass;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 3: Dynamic class_exists check (for newly added controllers)
|
||||
$appClass = self::APP_NAMESPACE . $directName;
|
||||
if (class_exists($appClass)) {
|
||||
self::$availableControllers[strtolower($directName)] = $appClass;
|
||||
return $appClass;
|
||||
}
|
||||
|
||||
$vendorClass = self::VENDOR_NAMESPACE . $directName;
|
||||
if (class_exists($vendorClass)) {
|
||||
self::$availableControllers[strtolower($directName)] = $vendorClass;
|
||||
return $vendorClass;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a controller in the pre-scanned available controllers
|
||||
*/
|
||||
private static function findInAvailable(string $controllerName): ?string
|
||||
{
|
||||
$key = strtolower($controllerName);
|
||||
return self::$availableControllers[$key] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert kebab-case to PascalCase
|
||||
* 'admin-user-settings' → 'AdminUserSettings'
|
||||
*/
|
||||
private static function kebabToPascal(string $kebab): string
|
||||
{
|
||||
return implode('', array_map('ucfirst', explode('-', $kebab)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-scan all controller directories and cache the available controllers
|
||||
* This is called once at server startup or on first request
|
||||
*/
|
||||
public static function scanControllers(): void
|
||||
{
|
||||
if (self::$scanned) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Scan app controllers (including subfolders)
|
||||
$appPath = self::getAppControllersPath();
|
||||
if ($appPath && is_dir($appPath)) {
|
||||
self::scanDirectory($appPath, self::APP_NAMESPACE);
|
||||
}
|
||||
|
||||
// Scan vendor controllers
|
||||
$vendorPath = __DIR__ . '/Controllers';
|
||||
if (is_dir($vendorPath)) {
|
||||
self::scanDirectory($vendorPath, self::VENDOR_NAMESPACE);
|
||||
}
|
||||
|
||||
self::$scanned = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively scan a directory for controller classes
|
||||
*/
|
||||
private static function scanDirectory(string $path, string $namespace, string $subNamespace = ''): void
|
||||
{
|
||||
$iterator = new \DirectoryIterator($path);
|
||||
|
||||
foreach ($iterator as $item) {
|
||||
if ($item->isDot()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($item->isDir()) {
|
||||
// Recurse into subdirectory
|
||||
$folderName = $item->getFilename();
|
||||
$newSubNamespace = $subNamespace . $folderName . '\\';
|
||||
self::scanDirectory($item->getPathname(), $namespace, $newSubNamespace);
|
||||
} elseif ($item->isFile() && $item->getExtension() === 'php') {
|
||||
$fileName = $item->getBasename('.php');
|
||||
|
||||
// Only consider files ending with 'Controller'
|
||||
if (!str_ends_with($fileName, 'Controller')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$fullClass = $namespace . $subNamespace . $fileName;
|
||||
|
||||
// Verify the class exists (triggers autoload)
|
||||
if (class_exists($fullClass, true)) {
|
||||
// Store with lowercase key for case-insensitive lookup
|
||||
$key = strtolower($fileName);
|
||||
self::$availableControllers[$key] = $fullClass;
|
||||
|
||||
// Also store with folder prefix for folder-based lookup
|
||||
if ($subNamespace) {
|
||||
$folderKey = strtolower(str_replace('\\', '', $subNamespace) . $fileName);
|
||||
self::$availableControllers[$folderKey] = $fullClass;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the app controllers path
|
||||
*/
|
||||
private static function getAppControllersPath(): ?string
|
||||
{
|
||||
// Try Laravel's app_path if the application is fully booted
|
||||
if (function_exists('app_path')) {
|
||||
try {
|
||||
$path = app_path('Websocket/Controllers');
|
||||
if (is_dir($path)) {
|
||||
return $path;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// app_path() might fail if app isn't booted - fall through to fallback
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: try common locations
|
||||
$basePaths = [
|
||||
defined('BASE_PATH') ? BASE_PATH : null,
|
||||
getcwd(),
|
||||
dirname(__DIR__, 5), // Go up from vendor package
|
||||
dirname(__DIR__, 4),
|
||||
];
|
||||
|
||||
foreach (array_filter($basePaths) as $basePath) {
|
||||
$path = $basePath . '/app/Websocket/Controllers';
|
||||
if (is_dir($path)) {
|
||||
return $path;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the controller cache (useful for testing or hot reload)
|
||||
*/
|
||||
public static function clearCache(): void
|
||||
{
|
||||
self::$controllerCache = [];
|
||||
self::$availableControllers = [];
|
||||
self::$scanned = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache statistics (for debugging)
|
||||
*
|
||||
* @return array{cached: int, available: int, scanned: bool}
|
||||
*/
|
||||
public static function getStats(): array
|
||||
{
|
||||
return [
|
||||
'cached' => count(self::$controllerCache),
|
||||
'available' => count(self::$availableControllers),
|
||||
'scanned' => self::$scanned,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Force preload of all controllers (call at server startup)
|
||||
*/
|
||||
public static function preload(): void
|
||||
{
|
||||
self::scanControllers();
|
||||
}
|
||||
}
|
||||
|
|
@ -385,8 +385,9 @@ class Handler implements MessageComponentInterface
|
|||
$ipc->setupChild();
|
||||
|
||||
try {
|
||||
// Lazy DB reconnect: disconnect now, reconnect only when first query runs
|
||||
// This saves ~5-15ms for methods that don't use the database
|
||||
DB::disconnect();
|
||||
DB::reconnect();
|
||||
|
||||
$this->setRequest($message, $connection);
|
||||
|
||||
|
|
@ -516,8 +517,9 @@ class Handler implements MessageComponentInterface
|
|||
string $requestId
|
||||
): void {
|
||||
try {
|
||||
// Lazy DB reconnect: disconnect now, reconnect only when first query runs
|
||||
// This saves ~5-15ms for methods that don't use the database
|
||||
DB::disconnect();
|
||||
DB::reconnect();
|
||||
|
||||
$this->setRequest($message, $connection);
|
||||
$mock = new MockConnection($connection, $requestId);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,99 @@
|
|||
<?php
|
||||
|
||||
namespace BlaxSoftware\LaravelWebSockets\Tests\Unit;
|
||||
|
||||
use BlaxSoftware\LaravelWebSockets\Websocket\ControllerResolver;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class ControllerResolverTest extends TestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
ControllerResolver::clearCache();
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_resolves_simple_controller_names()
|
||||
{
|
||||
// The package has a PusherController in vendor namespace
|
||||
$result = ControllerResolver::resolve('pusher');
|
||||
|
||||
$this->assertNotNull($result);
|
||||
$this->assertStringContainsString('PusherController', $result);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_caches_resolved_controllers()
|
||||
{
|
||||
// First call scans and caches
|
||||
ControllerResolver::resolve('pusher');
|
||||
|
||||
$stats = ControllerResolver::getStats();
|
||||
$this->assertTrue($stats['scanned']);
|
||||
$this->assertGreaterThan(0, $stats['cached']);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_caches_null_for_nonexistent_controllers()
|
||||
{
|
||||
// First call - not found
|
||||
$result1 = ControllerResolver::resolve('nonexistent-controller-xyz');
|
||||
$this->assertNull($result1);
|
||||
|
||||
// Second call - should still be null (cached)
|
||||
$result2 = ControllerResolver::resolve('nonexistent-controller-xyz');
|
||||
$this->assertNull($result2);
|
||||
|
||||
// Check it's in cache
|
||||
$stats = ControllerResolver::getStats();
|
||||
$this->assertGreaterThan(0, $stats['cached']);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_converts_kebab_case_to_pascal_case()
|
||||
{
|
||||
// admin-user should try AdminUserController
|
||||
// This tests the internal conversion (we can't directly test private method,
|
||||
// but we can test the resolve behavior)
|
||||
$stats = ControllerResolver::getStats();
|
||||
$this->assertIsArray($stats);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_clears_cache_correctly()
|
||||
{
|
||||
// Populate cache
|
||||
ControllerResolver::resolve('pusher');
|
||||
|
||||
$statsBefore = ControllerResolver::getStats();
|
||||
$this->assertTrue($statsBefore['scanned']);
|
||||
|
||||
// Clear cache
|
||||
ControllerResolver::clearCache();
|
||||
|
||||
$statsAfter = ControllerResolver::getStats();
|
||||
$this->assertFalse($statsAfter['scanned']);
|
||||
$this->assertEquals(0, $statsAfter['cached']);
|
||||
$this->assertEquals(0, $statsAfter['available']);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_preloads_controllers()
|
||||
{
|
||||
ControllerResolver::preload();
|
||||
|
||||
$stats = ControllerResolver::getStats();
|
||||
$this->assertTrue($stats['scanned']);
|
||||
$this->assertGreaterThan(0, $stats['available']);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_returns_same_result_on_repeated_calls()
|
||||
{
|
||||
$result1 = ControllerResolver::resolve('pusher');
|
||||
$result2 = ControllerResolver::resolve('pusher');
|
||||
|
||||
$this->assertSame($result1, $result2);
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Loading…
Reference in New Issue