Skip to content
academia.sh

Lesson 02 / 21

The Request–Response Lifecycle

The boot phases of service providers, the way the kernel handles a request, and the way work that runs after the response is sent stays outside the response; a comparison of deferred work with work called directly.

Published Updated

In this lesson

  • Reads the register and boot phases of boot, and the order of those phases, in a service provider model
  • Measures that work registered to run after the response leaves no trace in the response body and runs after the finish() call
  • Compares the same work falling under ran when called directly in the controller and under deferred when left until after the response
  • Measures in two requests whether a part set up during boot runs within the request window
Contents

The previous lesson showed that in the fourth request three parts were not inside the response: two ran in the worker and one ran after the response was sent. This lesson follows when work runs, in order, over the life of one request. A framework does not treat a request as a single call; it divides it into boot, handling and sending phases, and each phase has its own records. If the order of the phases is not known, it is also impossible to understand why a part does not appear in this request.

The Phases of a Request

In an application written with a framework, the life of a request is divided into four separate phases, and each phase has a different owner.

Phase What happens Whose code
Boot Providers are registered and booted The application’s providers
Handling The kernel finds the action that serves the request and calls it The framework calls, the developer writes the action
Sending The response returned by the action is handed to the client The framework
After the response Registered jobs run A job the developer registered, a call the framework makes

The boundary between the third and fourth rows of the table is the subject of this lesson. At the moment the client receives the response, the framework may still hold jobs that have not run. It is easy to see that these jobs do not mix into the content of the response; the real question is how we can know whether they actually ran.

Boot and Service Providers

Boot is the phase in which the framework sets itself up before it handles the first request. In this phase the framework calls the service providers: each provider is a registration unit that introduces one part of the application to the framework. A provider’s work is divided into two phases. In the register phase the provider only declares what it offers and does not touch what another provider offers. In the boot phase all providers have been registered, so the provider can now rely on what the others offer. The phases do not run per provider but for all providers in turn: first everyone’s registration, then everyone’s boot.

The lifecycle.php below is a small model of this lifecycle. Two of the registration calls in Kernel are the heart of the model: provider() registers a provider’s register and boot functions, and action() registers the part that will be called at the end of the request. boot() runs the two phases in order, handle() handles the request, and finish() is called after the response has gone to the client.

<?php
// lifecycle.php — a small lifecycle with service providers
declare(strict_types=1);
require_once 'ran.php';

final class Response {
    public function __construct(public int $status, public string $body = '') {}
}

final class Kernel {
    public array $phases = [];
    private array $providers = [];
    private array $watchers = [];
    private array $after = [];
    private ?array $action = null;
    public function provider(Closure $register, Closure $boot): void {
        $this->providers[] = [$register, $boot];
    }
    public function watcher(string $part, Closure $job): void { $this->watchers[$part] = $job; }
    public function action(string $part, Closure $job): void { $this->action = [$part, $job]; }
    public function afterResponse(string $part, Closure $job): void {
        Ledger::defer($part);
        $this->after[$part] = $job;
    }
    public function boot(): void {
        foreach ($this->providers as [$register]) $register($this);     // phase 1: register
        foreach ($this->providers as [, $boot]) $boot($this);           // phase 2: boot
    }
    public function handle(): Response {
        Ledger::newRequest();
        $this->after = [];
        foreach ($this->watchers as $part => $job) {
            Ledger::onPath($part); Ledger::ran($part); $job();
        }
        [$part, $job] = $this->action;
        Ledger::onPath($part); Ledger::ran($part);
        return $job($this);
    }
    public function finish(): array {          // the response went to the client
        foreach ($this->after as $job) $job();
        return array_keys($this->after);
    }
}

This class attaches to the ran.php core with three calls. The same twenty-two lines are given again in every lesson.

<?php
// ran.php — did an expected part run in this request?
declare(strict_types=1);

final class Ledger {
    public static array $path = [];        // parts placed on the request's path
    public static array $ran = [];         // parts called within the request
    public static array $deferred = [];    // parts left outside the request
    public static function newRequest(): void { self::$path = self::$ran = self::$deferred = []; }
    public static function onPath(string $part): void { self::$path[] = $part; }
    public static function ran(string $part): void { self::$ran[] = $part; }
    public static function defer(string $part): void { self::$deferred[] = $part; }
}

function classify(string $part): string {
    return match (true) {
        in_array($part, Ledger::$ran, true) => 'ran',
        in_array($part, Ledger::$deferred, true) => 'deferred',
        in_array($part, Ledger::$path, true) => 'skipped',
        default => 'not wired',
    };
}

FF6. boot() is two separate loops, and this is deliberate. The second loop does not start until the first has finished, so provider B’s registration runs before provider A’s boot. In its boot, A can rely on what B offers, and the reverse is also true. With a single loop, the order of the providers would break this assurance. handle() is independent of the boot() call: the model stands for a process in which boot runs once and many requests are then handled. It would also be wrong to make a provider depend, in the register phase, on what another provides: at that moment the other provider may not have registered yet, and nothing has been bound. In the register phase a provider only declares; use is left to the boot phase.

In the C Fundamentals course, the compilation stages (preprocessing, compilation, assembly, linking) were separate steps that ran before the program ran. There is no such separation here: boot, register, handling and the after-response phase all run at run time, and all four outcomes are at run time. Deferred is not a stage; it is the work being left outside the response.

Work That Runs After the Response

A controller sometimes wants a piece of work that must not delay the response: incrementing a counter, writing a statistics row. If the work does not affect the content of the response, there is no reason for the client to wait for it. For this the framework offers afterResponse(): the work is registered, the response is built and sent to the client, and then finish() runs it. The statistics job is measured in two ways. In one, the action leaves the job with afterResponse(); in the other, it calls the job directly and reports it with Ledger::ran().

<?php
// request-lifecycle.php — work that runs after the response stays outside the response
declare(strict_types=1);
require_once 'lifecycle.php';

function build(bool $direct): Kernel {
    $k = new Kernel();
    $k->provider(
        fn(Kernel $k) => $k->phases[] = 'register A',
        fn(Kernel $k) => $k->phases[] = 'boot A',
    );
    $k->provider(
        fn(Kernel $k) => $k->phases[] = 'register B',
        fn(Kernel $k) => $k->phases[] = 'boot B',
    );
    $k->action('save', function (Kernel $k) use ($direct): Response {
        $job = fn() => null;
        if ($direct) { Ledger::ran('statistics'); $job(); }
        else { $k->afterResponse('statistics', $job); }
        return new Response(201, 'saved');
    });
    $k->boot();
    return $k;
}

foreach ([false, true] as $direct) {
    $k = build($direct);
    echo implode(', ', $k->phases), "\n";
    $response = $k->handle();
    printf("%d %s | statistics %s\n", $response->status, $response->body, classify('statistics'));
    echo "after response: ", implode(', ', $k->finish()) ?: '-', "\n";
}
register A, register B, boot A, boot B
201 saved | statistics deferred
after response: statistics
register A, register B, boot A, boot B
201 saved | statistics ran
after response: -

FF7. The response of the two runs is the same: 201 and saved. There is not a single trace of statistics in the body; the only thing that separates the two is which list Ledger found the job in. The job left with afterResponse() entered the $deferred list through the defer() call, had still not run when handle() returned, and finish() ran it after that. The job called directly ran before the response was built, inside the action.

In the model, sending is the handle() call returning a Response, and finish() comes after it. On a real server, sending is the output being handed to the client and the connection being closed; work that runs after the response runs in the same process and the same memory, but when the client is no longer waiting. This differs from the previous lesson’s worker: Queue leaves the job somewhere another process can pick it up, and its link to the request’s process is cut, whereas afterResponse() stays inside the request’s process. Both are under deferred, but their failure modes differ; if the process shuts down right after the response, the registered job may never run.

That the same work falls under two separate outcomes shows that the outcome depends not on the work itself but on where the work was registered. The decision order (ran, deferred, skipped, not wired) applies here too: in the first run statistics is not in the $ran list but in the $deferred list; in the second it is in the $ran list, and the order ends there. The developer named the expected part: the same name, statistics, was queried in both runs.

This lesson’s outcome is deferred. Deferred work is not lost, and it runs outside the response; but if an error occurs, the response has already gone as 201 and the error does not show in the result of the request. The order of danger among the outcomes applies here too. A skipped part shows itself through a status code, a deferred part shows itself in a worker or after finish(); the most dangerous outcome is still not wired, because it shows nowhere. Whether deferred work is not wired, that is, whether the afterResponse() call was ever made, is again told only by the name the developer expected.

Which Work Is Left Until After the Response

Leaving a piece of work until after the response is not always right. The question that makes the distinction is whether the response depends on the result of that work. If the action saves a record and returns the record’s number in the response, the number comes from the record itself and the work must be called directly. If the response does not depend on the result of the work, as with a statistics row, the work can be left until after the response and the client does not wait for it. If the work fails later, there is no response left to tell the client, so the failure of the deferred work must be announced through another channel, a log or an alert; choosing that channel is not the subject of this lesson.

The two runs also share one point: the action was called in both cases and returned 201 in both cases. The response code gives no information about whether the work ran. The work itself is looked up in Ledger. So when a developer asks “was the statistics row written”, the response being 201 is not the answer; the answer is which list the work is found in.

The Bounding Measurement

Boot runs once, requests many times. Does a part set up in a provider’s boot phase run in every request? We measure this with two parts: loadSettings, which is called only once in the boot phase, and timer, which is registered in the same phase and then called in every request. Two requests are handled, and in each the two names are classified.

<?php
// bootstrap.php — does a part set up during boot run in every request
declare(strict_types=1);
require_once 'lifecycle.php';

$k = new Kernel();
$k->provider(
    fn(Kernel $k) => null,
    function (Kernel $k): void {
        Ledger::ran('loadSettings');                 // in the boot phase, before any request starts
        $k->watcher('timer', fn() => null);          // the part to be called in every request
    },
);
$k->action('show', fn() => new Response(200, 'ok'));
$k->boot();
printf("after boot  loadSettings %s\n", classify('loadSettings'));
foreach ([1, 2] as $n) {
    $k->handle();
    printf("request %d  loadSettings %-11s timer %s\n", $n, classify('loadSettings'), classify('timer'));
}
after boot  loadSettings ran
request 1  loadSettings not wired   timer ran
request 2  loadSettings not wired   timer ran

FF8. The expected result is that the two parts fall under different outcomes, and the measurement confirmed it. Right after boot, loadSettings appears as ran, because Ledger is still holding the boot record. The first handle() call empties the lists with newRequest(), and loadSettings is not wired in both requests: the part really did run, but outside the request’s window. timer, on the other hand, ran in both requests. What is set up in boot runs in every request; what runs in boot does not. Here not wired describes not a fault but the work being left outside the measurement window.

The practical counterpart of this distinction is as follows: adding a watcher in a provider’s boot makes that watcher run in every request; but a preparation step that the provider performs during boot, such as reading a settings file, is done once and is not repeated in later requests. If a developer expects a part to run “in every request”, the part should be registered on the request’s path like a watcher, not in boot. Work placed in boot runs once, and someone who expects it in every request will not find it in the second.

The limit of this result is the model itself. The model calls boot once; in an arrangement that loads the script from scratch on every request, boot would fall inside every request and loadSettings would run in every request. Which arrangement is used is determined not by the framework but by the process in which the application is run.

Summary

  • Boot runs in two phases: first the registration of all service providers, then all their boots; so a provider can rely in its boot on what the others offer.
  • All four outcomes are at run time; deferred is not a stage but the work being left outside the response.
  • Work left with afterResponse() leaves no trace in the response body and runs after the finish() call; if the same work is called directly in the action, it falls under ran.
  • The timer set up in boot ran in both requests; loadSettings, which ran in boot, appeared as not wired in both requests.
  • This lesson’s outcome is deferred: the work runs after the response, and its failure does not show in the response.

Next Step

In this lesson the action the request lands on was given ready-made: a single action was registered and every request went to it. The next lesson, Routing, asks about the case where more than one registration fits the same request: if two patterns fit the same path, whose action is called, and is the other part never attached to the request at all?

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close