NeuroAgent

How RoadRunner PHP Workers Work: Principles and Best Practices

Detailed explanation of how RoadRunner PHP workers operate, including routing optimization and database connection management for maximum performance.

How exactly does the script work on RoadRunner?

I have the following script running on RoadRunner:

php
use Nyholm\Psr7;
use Psr\Http\Message\ServerRequestInterface;
use Spiral\RoadRunner;
use Zend\Diactoros\Response\HtmlResponse;
use Zend\Diactoros\Response\JsonResponse;

$worker = RoadRunner\Worker::create();
$psrFactory = new Psr7\Factory\Psr17Factory();

$worker = new RoadRunner\Http\PSR7Worker($worker, $psrFactory, $psrFactory, $psrFactory);
$router = new League\Route\Router;

while ($req = $worker->waitRequest()) {
    $response = "";
    try {
        $post = $req->getParsedBody();
        $path = $req->getUri()->getPath();
        $router->map('POST', '/some1[/]', function (ServerRequestInterface $req, array $args) {
            //...
        });
        $router->map('POST', '/some2[/]', function (ServerRequestInterface $req, array $args) {/**/});

        $response = $router->dispatch($req);
        $worker->respond($response);
    } catch (League\Route\Http\Exception\MethodNotAllowedException $e) {
        $worker->respond(new HtmlResponse('<b>Error</b>: ' . $e->getMessage(), 500));
    } catch (League\Route\Http\Exception\NotFoundException $e) {
        $worker->respond(new HtmlResponse('<b>Error</b>: ' . $e->getMessage(), 404));
    } catch (\Throwable $e) {
        //my handling
    }
}

I have several questions about how this script works:

  1. Which part of the code is executed once when the worker starts, and which part is executed for each user request? Am I correct in understanding that everything inside the while ($req = $worker->waitRequest()) loop is executed for each request, while the code outside this loop is only executed once during initialization?

  2. Artificial intelligence recommends not executing $router->map() inside the waitRequest() loop. Is this correct advice? (The script was inherited from a previous developer, and I’m trying to understand its logic)

  3. How should database connections be organized in such a script? Should I open the connection in the global part, before the while ($req = $worker->waitRequest()) loop, and inside the loop check the connection status as follows:

php
$connOk = false;
try {
    /* @var \mysqli $sql*/
    $connOk = @mysqli_ping($sql);
} catch (\Throwable $e) {
    // exception handling
}
if (!$connOk) {
    if (!empty($sql) && $sql instanceof \mysqli) {
        @mysqli_close($sql);
    }
    $sql = mysqli_connect(/**/);
}

Please explain the principles of how RoadRunner works and best practices for this case.

NeuroAgent

RoadRunner PHP Worker Scripts: Understanding the Worker Lifecycle Model

RoadRunner PHP worker scripts operate on a worker lifecycle model where initialization occurs once per worker process, and the worker handles multiple requests over its lifetime. In your script, the code outside the while ($req = $worker->waitRequest()) loop executes only once when the worker starts, while everything inside the loop processes each incoming request individually.

Contents


RoadRunner Worker Basics

RoadRunner implements a worker model based on Go goroutines, where each PHP worker operates as a separate process, exchanging messages with the main RoadRunner process. Key features:

  • Initialization: Each PHP worker initializes once at startup
  • Persistent operation: The worker remains active between requests, eliminating load time
  • Request processing: Code inside the while loop processes each request sequentially
  • Automatic reload: RoadRunner automatically restarts workers on errors or memory limit exceeded

As noted in the RoadRunner documentation, “RoadRunner keeps PHP workers alive between incoming requests. This means that you can completely eliminate bootload time (such as framework initialization) and significantly speed up a heavy application.”


Analysis of Your Script

Answer to Question 1: Code Separation by Execution Time

Your understanding is absolutely correct:

php
// Executes ONCE during worker initialization
$worker = RoadRunner\Worker::create();
$psrFactory = new Psr7\Factory\Psr17Factory();
$worker = new RoadRunner\Http\PSR7Worker($worker, $psrFactory, $psrFactory, $psrFactory);
$router = new League\Route\Router;

// Executes for EACH user request
while ($req = $worker->waitRequest()) {
    // all code here executes with each request
}

This architecture allows RoadRunner to achieve high performance, as expensive operations (dependency loading, framework initialization, route setup) are performed only once.


Route Optimization

Answer to Question 2: Router mapping inside the loop

The AI advice is correct. Placing $router->map() inside the while loop is a bad practice for several reasons:

  1. Inefficiency: Routes can be defined once during initialization
  2. Potential memory leaks: Each map() call may add new handlers
  3. Logical errors: Duplicate routes can lead to unpredictable behavior

Correct implementation:

php
// Executes once during initialization
$router = new League\Route\Router;
$router->map('POST', '/some1[/]', function (ServerRequestInterface $req, array $args) {
    // route handler
});
$router->map('POST', '/some2[/]', function (ServerRequestInterface $req, array $args) {
    // route handler
});

// Request processing loop
while ($req = $worker->waitRequest()) {
    try {
        $response = $router->dispatch($req);
        $worker->respond($response);
    } catch (League\Route\Http\Exception\MethodNotAllowedException $e) {
        $worker->respond(new HtmlResponse('<b>Error</b>: ' . $e->getMessage(), 500));
    } catch (League\Route\Http\Exception\NotFoundException $e) {
        $worker->respond(new HtmlResponse('<b>Error</b>: ' . $e->getMessage(), 404));
    } catch (\Throwable $e) {
        // your handling
    }
}

As explained in the RoadRunner PHP Workers guide, RoadRunner expects PHP scripts to run continuously, processing many requests through a while loop.


Database Work

Answer to Question 3: Optimal database organization

Your approach of checking the connection in the loop is reasonable, but it can be optimized. The recommended approach:

php
// Global scope - executes once
$sql = null;

function initDatabase() {
    global $sql;
    $sql = mysqli_connect(
        'localhost', 
        'user', 
        'password', 
        'database',
        3306,
        null,
        MYSQLI_CLIENT_FOUND_ROWS | MYSQLI_CLIENT_MULTI_STATEMENTS
    );
    
    if (!$sql) {
        throw new \RuntimeException("Database connection failed: " . mysqli_connect_error());
    }
    
    // Set timeouts and other parameters
    mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT);
}

// Initialize on worker startup
initDatabase();

// In the request processing loop
while ($req = $worker->waitRequest()) {
    try {
        // Check connection before use
        if (!$sql || !mysqli_ping($sql)) {
            initDatabase(); // Reconnect if necessary
        }
        
        // Use the connection
        $result = mysqli_query($sql, "SELECT * FROM users LIMIT 1");
        
    } catch (\Throwable $e) {
        // Error handling
        if ($e instanceof mysqli_sql_exception) {
            // Specific MySQL error handling
            error_log("Database error: " . $e->getMessage());
        }
        // ... rest of handling
    }
}

Key recommendations:

  1. Single connection: Use one connection per entire worker
  2. Lazy checking: Check the connection only before use
  3. Automatic reconnection: Implement a connection recovery mechanism
  4. Timeout handling: Set reasonable timeouts for database operations

As noted by RoadRunner developers, “Database connections and any pipe/socket is the potential point of failure. Close all the connections after each iteration. Consider calling gc_collect_cycles after each execution if you want to keep memory low.”


Performance Recommendations

Optimizing Your Script

php
<?php
use Nyholm\Psr7;
use Psr\Http\Message\ServerRequestInterface;
use Spiral\RoadRunner;
use Zend\Diactoros\Response\HtmlResponse;
use Zend\Diactoros\Response\JsonResponse;

// === INITIALIZATION (executes once) ===
$worker = RoadRunner\Worker::create();
$psrFactory = new Psr7\Factory\Psr17Factory();
$psr7Worker = new RoadRunner\Http\PSR7Worker($worker, $psrFactory, $psrFactory, $psrFactory);

// Routes are defined once
$router = new League\Route\Router;
$router->map('POST', '/some1[/]', function (ServerRequestInterface $req, array $args) {
    return new JsonResponse(['status' => 'success for some1']);
});
$router->map('POST', '/some2[/]', function (ServerRequestInterface $req, array $args) {
    return new JsonResponse(['status' => 'success for some2']);
});

// Database initialization
$sql = null;
function getDbConnection() {
    global $sql;
    if (!$sql || !mysqli_ping($sql)) {
        $sql = mysqli_connect('localhost', 'user', 'password', 'database');
        if (!$sql) {
            throw new \RuntimeException("Database connection failed");
        }
    }
    return $sql;
}

// === REQUEST PROCESSING (for each request) ===
while ($req = $psr7Worker->waitRequest()) {
    try {
        // Check and get database connection if needed
        $db = getDbConnection();
        
        // Process request
        $response = $router->dispatch($req);
        $psr7Worker->respond($response);
        
    } catch (League\Route\Http\Exception\MethodNotAllowedException $e) {
        $psr7Worker->respond(new HtmlResponse('<b>Error</b>: ' . $e->getMessage(), 405));
    } catch (League\Route\Http\Exception\NotFoundException $e) {
        $psr7Worker->respond(new HtmlResponse('<b>Error</b>: ' . $e->getMessage(), 404));
    } catch (\Throwable $e) {
        // Error logging
        error_log("Worker error: " . $e->getMessage());
        
        // Return error to client
        $psr7Worker->respond(new JsonResponse([
            'error' => 'Internal server error',
            'message' => $e->getMessage()
        ], 500));
    }
}

Additional Recommendations

  1. Connection pooling: For high load, consider using a connection pool
  2. Memory optimization: Periodically call gc_collect_cycles() when working with large data volumes
  3. Monitoring: Implement logging to track worker status
  4. Configuration: Configure RoadRunner parameters for your load:
    yaml
    # .rr.yaml
    server:
      command: "php worker.php"
      env:
        - APP_ENV=production
    workers:
      pool:
        num_workers: 4
        max_jobs: 1000
        exec_ttl: 30s
        supervisor:
          watch_tick: 1s
          ttl: 30s
    

Conclusion

  1. RoadRunner architecture: All code outside the while loop executes once during worker initialization, while inside the loop - for each request
  2. Routing: Route definition should occur outside the loop for performance and predictability
  3. Database: Use a single connection per worker with automatic recovery mechanism
  4. Error handling: Implement comprehensive exception handling with logging for problem diagnosis

Proper RoadRunner worker organization allows for significant performance gains compared to the traditional FPM approach, especially for applications with long framework initialization times.

Sources

  1. RoadRunner PHP Workers Documentation
  2. Scaling PHP Applications with RoadRunner
  3. RoadWorker GitHub Documentation
  4. RoadRunner Features Overview
  5. PHP RoadRunner Community Discussion