Skip to content

Worker Lifecycle

Ray Fung edited this page Feb 26, 2026 · 3 revisions

Worker Lifecycle

Razy includes a Worker Lifecycle system for long-running processes (e.g., Caddy worker mode, Swoole, RoadRunner). It monitors file changes, manages request inflight counts, handles drain/restart signalling, and supports hot-swap rebinding — all without restarting the entire PHP process.


Table of Contents


Overview

In worker mode, a single PHP process stays alive between HTTP requests. This means:

  1. Code changes are not picked up automatically — the running process still uses the originally loaded classes.

  2. Configuration changes may require a full restart.

  3. Container rebindings (e.g., swapping a service implementation) can be hot-applied.

The Worker Lifecycle system addresses all three scenarios.


Boot →Ready →[handle requests]

                    │

              checkForChanges()

                    │

                    ┌─────────┴┴───────────┐

    ClassFile    Rebindable    Config

   (restart)   (hot-swap)   (restart)

        │          │          │

   beginDrain   rebind      beginDrain

        │      continue        │

   Draining                 Draining

        │                      │

   Terminated               Terminated


WorkerState

An enum representing the lifecycle phases:

use Razy\Worker\WorkerState;



WorkerState::Booting;     // Process is initializing

WorkerState::Ready;       // Accepting requests

WorkerState::Draining;    // Finishing in-flight requests, rejecting new ones

WorkerState::Swapping;    // Hot-swap in progress

WorkerState::Terminated;  // Process should exit

Helper Methods

$state = WorkerState::Ready;



$state->canAcceptRequests();  // true  (only Booting and Ready return true)

$state->shouldExit();         // false (only Draining and Terminated return true)

ChangeType

Classifies detected changes by severity:

use Razy\Worker\ChangeType;



ChangeType::None;         // No change detected

ChangeType::Config;       // Configuration file changed → requires restart

ChangeType::Rebindable;   // Container binding changed → can hot-swap

ChangeType::ClassFile;    // PHP class file changed → requires restart

Helper Methods

$change = ChangeType::Rebindable;



$change->requiresRestart();  // false

$change->canHotSwap();       // true

$change->canRebind();        // true

$change->severity();         // 2  (None=0, Rebindable=1, Config=2, ClassFile=3)

Severity order: None < Rebindable < Config < ClassFile

When multiple changes are detected, the highest severity wins.


WorkerLifecycleManager

The central coordinator. It combines signal checking, file change detection, and container rebinding into a single checkForChanges() call.

Booting

use Razy\Worker\WorkerLifecycleManager;

use Razy\Worker\RestartSignal;

use Razy\Worker\ModuleChangeDetector;

use Razy\Container;



$container = new Container();

$detector  = new ModuleChangeDetector($watchPaths);

$signal    = new RestartSignal('/tmp/razy-worker-signal.json');



$manager = new WorkerLifecycleManager(

    container: $container,

    detector: $detector,

    signal: $signal,

    drainTimeout: 30,      // seconds to wait for inflight requests

    checkInterval: 5       // seconds between change checks

);



$manager->boot();  // Snapshots files, transitions to Ready

Checking for Changes

Call checkForChanges() on each request cycle (or on a timer):

$result = $manager->checkForChanges();



switch ($result) {

    case 'continue':

        // No changes, keep serving

        break;



    case 'rebound':

        // Container bindings were hot-swapped, keep serving

        break;



    case 'swapped':

        // A swap was signalled and applied

        break;



    case 'restart':

        // Class/config changes detected, must restart

        $manager->beginDrain();

        break;



    case 'draining':

        // Already draining, waiting for inflight to finish

        break;



    case 'terminate':

        // Drain complete or timeout, safe to exit

        exit(0);

        break;

}

The method internally:

  1. Checks RestartSignal for cross-process signals

  2. Consults ModuleChangeDetector for file changes

  3. If rebindable — triggers container rebind hooks

  4. If class/config change — recommends restart

  5. If draining and inflight = 0 (or timeout) → recommends termination

Request Tracking

Track in-flight requests for graceful shutdown:

// At request start

$manager->requestStarted();



// At request end

$manager->requestFinished();



// Check inflight count

$inflight = $manager->inflight();  // int

Draining

Begin draining to stop accepting new requests:

$manager->beginDrain();



// State transitions to Draining

// Once all inflight requests finish (or drain timeout elapses),

// checkForChanges() will return 'terminate'

State Inspection

$state = $manager->state();           // WorkerState enum

$manager->state()->canAcceptRequests(); // bool

$manager->state()->shouldExit();       // bool

RestartSignal

File-based JSON signalling mechanism for cross-process communication. A monitoring process (or CLI command) can write a signal file, and the worker process reads it.

use Razy\Worker\RestartSignal;



$signal = new RestartSignal('/tmp/razy-worker-signal.json');



// --- Sender side (CLI or monitoring process) ---



$signal->send(RestartSignal::ACTION_RESTART);

$signal->send(RestartSignal::ACTION_SWAP);

$signal->send(RestartSignal::ACTION_TERMINATE);



// --- Receiver side (worker process) ---



$action = $signal->check();

// Returns: 'restart' | 'swap' | 'terminate' | null



// Acknowledge and clear

$signal->clear();



// Check if signal is stale (older than threshold)

$isStale = $signal->isStale(seconds: 300);

Signal File Format

{

    "action": "restart",

    "timestamp": 1704067200,

    "pid": 12345

}

Constants

| Constant | Value | Description |

| --- | --- | --- |

| ACTION_RESTART | 'restart' | Full process restart |

| ACTION_SWAP | 'swap' | Hot-swap signal |

| ACTION_TERMINATE | 'terminate' | Graceful shutdown |


ModuleChangeDetector

Monitors PHP files for changes using MD5 hashing. It uses token_get_all() to classify files as:

  • Named classes — normal class/interface/trait/enum definitions

  • Anonymous classes — files containing anonymous classes (closures, factories)

  • Config files — non-class PHP files

use Razy\Worker\ModuleChangeDetector;



$detector = new ModuleChangeDetector([

    '/var/www/app/src',

    '/var/www/app/config',

]);



// Take initial snapshot (called during boot)

$detector->snapshot();



// Detect changes in a specific path

$changeType = $detector->detect('/var/www/app/src/Services/PaymentService.php');

// Returns: ChangeType enum



// Detect all changes across all watched paths

$changes = $detector->detectAll();

// Returns: array of ['path' => string, 'type' => ChangeType]



// Get overall change severity

$overall = $detector->detectOverall();

// Returns: ChangeType (highest severity across all changes)

Classification Logic

| Token Pattern | Classification | Change Severity |

| --- | --- | --- |

| T_CLASS, T_INTERFACE, T_TRAIT, T_ENUM with name | Named class file | ClassFile (requires restart) |

| T_NEW followed by T_CLASS | Anonymous class file | Rebindable (can hot-swap) |

| No class tokens | Config / script file | Config (requires restart) |

Severity Aggregation

When detectOverall() finds multiple changes, it returns the highest severity:

// If both a config file and a rebindable file changed:

$overall = $detector->detectOverall();

// Returns ChangeType::Config (severity 2 > 1)

Integration Example

A complete Caddy worker-mode loop:

use Razy\Worker\WorkerLifecycleManager;

use Razy\Worker\RestartSignal;

use Razy\Worker\ModuleChangeDetector;

use Razy\Container;



// --- Bootstrap (runs once) ---

$container = new Container();

// ... register services ...



$manager = new WorkerLifecycleManager(

    container: $container,

    detector: new ModuleChangeDetector([__DIR__ . '/src', __DIR__ . '/config']),

    signal: new RestartSignal(sys_get_temp_dir() . '/razy-worker.json'),

    drainTimeout: 30,

    checkInterval: 5

);



$manager->boot();



// --- Request loop ---

while ($request = waitForRequest()) {  // framework-specific

    // Pre-flight check

    $result = $manager->checkForChanges();

    if ($result === 'terminate') {

        break;

    }

    if (!$manager->state()->canAcceptRequests()) {

        respondWith503($request);

        continue;

    }



    $manager->requestStarted();



    try {

        $response = handleRequest($request, $container);

        sendResponse($response);

    } finally {

        $manager->requestFinished();

    }

}



// Cleanup

exit(0);

Monitoring Script (Separate Process)

use Razy\Worker\RestartSignal;



$signal = new RestartSignal(sys_get_temp_dir() . '/razy-worker.json');



// After deploying new code:

$signal->send(RestartSignal::ACTION_RESTART);

echo "Restart signal sent.\n";



// For hot-swap of container bindings:

$signal->send(RestartSignal::ACTION_SWAP);

echo "Swap signal sent.\n";

API Reference

WorkerState

| Method | Returns | Description |

| --- | --- | --- |

| canAcceptRequests() | bool | true for Booting and Ready |

| shouldExit() | bool | true for Draining and Terminated |

ChangeType

| Method | Returns | Description |

| --- | --- | --- |

| requiresRestart() | bool | true for Config and ClassFile |

| canHotSwap() | bool | true for Rebindable |

| canRebind() | bool | true for Rebindable |

| severity() | int | None=0, Rebindable=1, Config=2, ClassFile=3 |

WorkerLifecycleManager

| Method | Signature | Description |

| --- | --- | --- |

| __construct | (Container, ModuleChangeDetector, RestartSignal, int $drainTimeout = 30, int $checkInterval = 5) | Create manager |

| boot | (): void | Snapshot files, transition to Ready |

| checkForChanges | (): string | Returns 'continue'\|'restart'\|'rebound'\|'swapped'\|'draining'\|'terminate' |

| requestStarted | (): void | Increment inflight counter |

| requestFinished | (): void | Decrement inflight counter |

| inflight | (): int | Current inflight count |

| beginDrain | (): void | Transition to Draining state |

| state | (): WorkerState | Current lifecycle state |

RestartSignal

| Method | Signature | Description |

| --- | --- | --- |

| __construct | (string $filePath) | Signal file path |

| check | (): ?string | Read signal action or null |

| send | (string $action): void | Write signal file |

| clear | (): void | Delete signal file |

| isStale | (int $seconds = 300): bool | Check if signal is outdated |

ModuleChangeDetector

| Method | Signature | Description |

| --- | --- | --- |

| __construct | (array $watchPaths) | Paths to monitor |

| snapshot | (): void | Record current file hashes |

| detect | (string $path): ChangeType | Detect change in one file |

| detectAll | (): array | All changes as [path, type] pairs |

| detectOverall | (): ChangeType | Highest severity change |

← Previous: FTP & SFTP

Error-Handling

Clone this wiki locally