Writing the Worker Loop: Managing the Goridge protocol.

Lesson 3 60 min

Writing the Worker Loop: Mastering the Goridge Protocol for High-Scale PHP

Component Architecture

Client Load Balancer RoadRunner (Supervisor) HTTP Server PHP Worker (Persistent Process) Application Logic HTTP/S Goridge IPC (Response)

Welcome back, engineers! In our journey to architect a High-Scale PHP CMS, we've laid the groundwork with RoadRunner's supervisor configuration. Today, we dive into the very heart of our application: the PHP worker loop. This isn't just about writing code; it's about understanding the fundamental shift in PHP execution that unlocks true high-performance and enables us to handle those ambitious hundred-million requests per second.

Forget the old "bootload tax" of traditional PHP-FPM where every request meant re-initializing your entire framework. With RoadRunner, our PHP script becomes a long-running process, continuously serving requests. The magic that makes this persistent runtime possible, and incredibly efficient, is the Goridge protocol.

The Heartbeat: Why a Worker Loop?

In the traditional shared-nothing PHP model, each request spins up a fresh PHP process, loads all your framework's dependencies, connects to the database, processes the request, and then tears everything down. This "bootload tax" is acceptable for low-traffic sites but becomes an insurmountable bottleneck at scale.

Our RoadRunner setup flips this on its head. RoadRunner spawns a pool of PHP worker processes, but these processes don't die after one request. Instead, they enter a perpetual loop, waiting for RoadRunner to hand them a new request. This loop is the "worker loop," and it's where your application logic lives.

Core Concepts: Beyond the Request-Response

  1. Persistent State and Reduced Bootload: This is the game-changer. Imagine your framework takes 50ms to bootstrap. With FPM, that's 50ms per request. With RoadRunner, it's 50ms once when the worker starts. Subsequent requests immediately hit your application logic, drastically reducing latency and increasing throughput. Database connections, caches, DI containers – all can be initialized once and reused. This isn't just an optimization; it's a paradigm shift for PHP.

  2. Inter-Process Communication (IPC) via Goridge: RoadRunner and your PHP worker are separate processes. They need a fast, efficient way to talk. Goridge is that language. It's a binary RPC (Remote Procedure Call) protocol designed specifically for high-performance communication between Go (RoadRunner) and other languages (like PHP). It's much faster and has less overhead than traditional HTTP or even plain TCP for internal process communication because it's optimized for structured data exchange, minimizing serialization/deserialization costs.

  3. The Supervisor Model (RoadRunner's Role): RoadRunner isn't just a proxy; it's a smart supervisor. It manages the lifecycle of your PHP workers: starting them, monitoring them, restarting them if they crash, and load-balancing requests across the worker pool. This provides resilience and ensures your application remains available even if individual workers encounter issues.

Component Architecture & Control Flow

Flowchart

Client Request RoadRunner: Accept HTTP Request RoadRunner: Dispatch to PHP Worker Goridge: Send Request Payload PHP Worker: Receive Request PHP Worker: Process Application Logic Goridge: Send Response Payload RoadRunner: Send HTTP Response

Let's visualize how this fits together.

  • Client Request: A user's browser sends a request.

  • Load Balancer/Reverse Proxy: Distributes the request to one of our RoadRunner instances.

  • RoadRunner:

  1. Receives the HTTP request.

  2. Picks an available PHP worker from its pool.

  3. Serializes the request data (headers, body, method, path) into a Goridge payload.

  4. Sends this Goridge payload to the chosen PHP worker over a Unix socket or TCP connection.

  • PHP Worker (Our Focus Today):

  1. Enters its infinite loop.

  2. Waits for a Goridge message from RoadRunner.

  3. Deserializes the Goridge payload back into a standard PHP request object.

  4. Executes your application logic (e.g., routing, controller, database interaction).

  5. Serializes the response data (status code, headers, body) into a Goridge payload.

  6. Sends this Goridge payload back to RoadRunner.

  • RoadRunner:

  1. Receives the Goridge response.

  2. Deserializes it.

  3. Constructs an HTTP response.

  4. Sends the HTTP response back to the client.

This continuous dance, orchestrated by Goridge, is what allows a single PHP worker to serve thousands, even millions, of requests efficiently.

Hands-on: Building Our First Worker Loop

State Machine

Worker Init (Load Framework) Waiting for Request Processing Request Sending Response Error Handling (Log and Respond 500) Bootstrap Complete Request Received (Goridge) Logic Complete Response Sent Worker Loop Exception Fatal Error Worker Crashed / Exit

Our goal is to create a simple PHP script that understands the Goridge protocol, receives a request, processes it, and sends a response. This script will be the blueprint for all future complex CMS logic.

php
fromGlobals();

// Log the incoming request (for debugging/demonstration)
error_log(sprintf(
"Worker received request: %s %s",
$request->getMethod(),
$request->getUri()->getPath()
));

// 2. Process the request (Your CMS logic goes here!)
// For this lesson, let's keep it simple: echo back some info.
$path = $request->getUri()->getPath();
$query = $request->getUri()->getQuery();
$method = $request->getMethod();
$headers = $request->getHeaders();

$responseBody = sprintf(
"Hello from PHP Worker! You requested: %s %snPath: %snQuery: %sn",
$method,
$path,
$path,
$query
);

// Add a header to demonstrate statefulness or worker ID
$responseBody .= "Worker ID: " . getmypid() . "n";

// 3. Create a PSR-7 response.
$response = $psr17Factory->createResponse(200)
->withHeader('Content-Type', 'text/plain')
->withHeader('X-Worker-ID', (string)getmypid())
->withBody($psr17Factory->createStream($responseBody));

// 4. Send the response back to RoadRunner via Goridge.
$worker->respond($response);

} catch (Throwable $e) {
// Critical insight: Handle exceptions gracefully within the loop.
// If an exception occurs, we must still respond to RoadRunner,
// otherwise, RoadRunner will assume the worker is hung.
// This is a minimal error response. In a real system, you'd log
// extensively and potentially send a more detailed error page.
error_log("Worker error: " . $e->getMessage() . "n" . $e->getTraceAsString());

try {
$errorResponse = $psr17Factory->createResponse(500)
->withHeader('Content-Type', 'text/plain')
->withBody($psr17Factory->createStream("Internal Server Error: " . $e->getMessage()));
$worker->respond($errorResponse);
} catch (Throwable $e2) {
// If even responding with an error fails, something is severely wrong.
// Log and exit to let RoadRunner restart the worker.
error_log("Failed to send error response: " . $e2->getMessage());
exit(255); // Exit with a non-zero code to signal RoadRunner to restart.
}
}
}

This simple script is powerful. It demonstrates:

  • The one-time setup: Anything before the while(true) loop runs only once.

  • The persistent loop: The while(true) ensures the worker keeps serving requests.

  • Goridge interaction: Worker::fromGlobals() (part of psr7-server) transparently uses Goridge to read the request, and Worker::respond() sends the response.

  • Error handling: Crucial for stability. A worker must always respond, even if with an error, or RoadRunner will think it's stalled.

Assignment: Build Your Goridge Worker

Your task is to implement the worker loop using the provided code skeleton.

  1. Project Setup: Ensure you have the cms-worker directory from Day 2.

  2. Dependencies: Install the necessary PHP packages: spiral/goridge, nyholm/psr7, nyholm/psr7-server.

  3. Worker Script: Create src/worker.php with the core worker loop logic provided above.

  4. RoadRunner Configuration: Update your rr.yaml to point to this new worker script.

  5. Test and Verify: Start RoadRunner and send several HTTP requests to observe the worker's persistent behavior. Pay attention to the X-Worker-ID header and the error_log output.

Success Criteria:

  • You can send multiple curl requests to your RoadRunner server.

  • Each request is correctly processed by your worker.php script.

  • The X-Worker-ID header in the response remains consistent across multiple requests (indicating the same PHP process handled them).

  • The error_log messages from the worker appear in your RoadRunner console.

This hands-on exercise will solidify your understanding of how Goridge powers a persistent PHP application, a cornerstone for our high-scale CMS.

Solution Hints:

  1. composer.json:

json
{
"require": {
"php": ">=8.1",
"spiral/goridge": "^3.0",
"nyholm/psr7": "^1.8",
"nyholm/psr7-server": "^1.0"
},
"autoload": {
"psr-4": {
"App": "src/"
}
}
}

Run composer install in your cms-worker directory.

  1. rr.yaml (simplified for this lesson, assuming cms-worker is your project root):

yaml
# .rr.yaml in cms-worker directory
server:
command: "php src/worker.php" # Path to your worker script
relay: "pipes" # Use pipes for communication (default for local dev)

http:
address: "0.0.0.0:8080"
workers:
pool:
num_workers: 2 # Start with a small pool for testing
max_requests: 1000 # Restart worker after 1000 requests to prevent memory leaks
debug: true # Enable debug output for workers

Make sure this rr.yaml is in the root of your cms-worker project.

  1. To run: Navigate to your cms-worker directory and execute ./rr serve -c .rr.yaml.

  2. To test: Open another terminal and run curl http://localhost:8080/hello?name=world multiple times. Observe the output, especially the X-Worker-ID header.

By completing this, you've taken a massive leap from traditional PHP to a truly high-performance, persistent application architecture. This Goridge-powered worker loop is the foundation upon which we'll build our entire high-scale CMS.

Need help?