NAME

PAGI::Spec::Lifespan - The PAGI Lifespan protocol for startup and shutdown events

Lifespan Protocol

Version: 0.3 (Draft)

The Lifespan PAGI sub-specification outlines how to communicate lifespan events such as startup and shutdown within PAGI.

The lifespan messages allow for an application to initialise and shutdown in the context of a running event loop. An example of this would be creating a connection pool and subsequently closing the connection pool to release the connections.

Execution Model

Lifespan events must be sent by every PAGI server instance or worker that manages an application event loop. This ensures that application-level resources are initialized and cleaned up in the context in which they run.

-

In multi-process deployments, each worker process must independently send lifespan.startup on init and lifespan.shutdown on exit.

-

In in-process async mode, the main event loop will send the events.

This matches the ASGI model and ensures that applications have the opportunity to manage state correctly in async-safe scopes.

Lifespans and requests must occur on the same event loop. This guarantees that async-local state (such as DB pools or background tasks) is not shared unsafely across threads or processes.

A possible implementation of this protocol in modern Perl using Future::AsyncAwait could look like this:

use strict;
use warnings;
use Future::AsyncAwait;

async sub app {
    my ($scope, $receive, $send) = @_;
    # Only handle 'lifespan' protocol
    if ($scope->{type} eq 'lifespan') {

        while (1) {
            my $message = await $receive->();

            if ($message->{type} eq 'lifespan.startup') {
                # ... Do some startup actions here ...
                await $send->({
                    type => 'lifespan.startup.complete',
                });
            }
            elsif ($message->{type} eq 'lifespan.shutdown') {
                # ... Do some shutdown/cleanup actions here ...
                await $send->({
                    type => 'lifespan.shutdown.complete',
                });
                return; # End the loop
            }
            else {
                # Unknown or additional message type; you can handle it or ignore it
            }
        }

    }
    else {
        die "Unsupported protocol type: $scope->{type}";
    }
}

Scope

The lifespan scope exists for the duration of the event loop.

The scope information passed in scope contains basic metadata:

-

type (String) - "lifespan".

-

The version key of the pagi hashref (accessed as $scope->{pagi}{version}) (String) - The version of the PAGI core spec that governs the worker.

-

The spec_version key of the pagi hashref (accessed as $scope->{pagi}{spec_version}) (String) - The version of this lifespan sub-specification. Optional; if missing, assume it matches version.

-

The is_worker key of the pagi hashref (accessed as $scope->{pagi}{is_worker}) (Int, optional) - 1 when running as a worker in a multi-process deployment; 0 in single-process mode. May be absent on servers that do not distinguish workers. Applications can use this to avoid duplicate initialization (e.g., only print startup messages from worker 1).

-

The worker_num key of the pagi hashref (accessed as $scope->{pagi}{worker_num}) (Int, optional) - Worker identifier (1, 2, 3, ...) in multi-process mode. Absent or undef for single-process mode. Combined with is_worker, applications can identify which worker they are running in.

-

state (HashRef, optional) - A namespace where the application can persist values to be copied (as a shallow copy; see "Lifespan State") into subsequent request scopes. Servers omit this key if the feature is unsupported.

If the application declines the lifespan protocol -- either by raising an exception when called with the lifespan scope, or by returning without ever sending lifespan.startup.complete or lifespan.startup.failed -- the server must treat lifespan as unsupported: it must continue startup, must not send further lifespan events for that worker, and should log this at an informational level. A raised exception and a clean return are resolved identically, and the server must not block startup indefinitely waiting for a lifespan signal.

To prevent startup entirely, an application must send lifespan.startup.failed with a message; on receiving it the server must abort startup (it must not begin accepting connections) and should log the message. This is the only application signal that prevents the server from starting -- a clean return must not.

If the application raises an exception after it has sent lifespan.startup.complete -- for example, a long-lived background task started during lifespan crashes -- the server should log the error at error level and may continue running in a degraded state. It is not required to shut down.

Lifespan State

Applications often want to persist data from the lifespan cycle to request/response handling. For example, a database connection can be established in the lifespan cycle and persisted to the request/response cycle.

The scope["state"] namespace provides a place to store these sorts of things. The server will ensure that a shallow copy of the namespace is passed into each subsequent request/response call into the application. Since the server manages the application lifespan and often the event loop as well this ensures that the application is always accessing the database connection (or other stored object) that corresponds to the right event loop and lifecycle, without using context variables, global mutable state or having to worry about references to stale/closed connections.

PAGI servers that implement this feature will provide state as part of the lifespan scope:

my $scope = {
    ...
    state => {},
};

The namespace is controlled completely by the PAGI application; the server will not interact with it other than to copy it. Nonetheless, applications should be cooperative by properly naming their keys such that they will not collide with other frameworks or middleware.

Because the copy is shallow, take care how you share mutable data:

  • The values in state (object references -- a database pool, a queue, a pub/sub hub) are shared. Every request scope sees the same object, so mutating it through that reference (calling a method, push-ing onto an arrayref) is visible everywhere.

  • The top-level keys are private to each copy. Assigning a top-level key in one scope -- $scope->{state}{count}++ in a request, or $state->{queue} = [] in the lifespan once requests are running -- changes only that scope's copy. Other scopes keep their own and silently desync.

So to share mutable data, store a container or object once at startup and mutate it through its reference; never replace a top-level state key and expect other scopes to see it. This mirrors the database-connection example above: you store the pool once and use it everywhere -- you never reassign it per request.

Startup - receive event

Sent to the application when the server is ready to startup and receive connections, but before it has started to do so.

Keys:

-

type (String) - "lifespan.startup".

Startup Complete - send event

Sent by the application when it has completed its startup. A server must wait for this message before it starts processing connections.

Keys:

-

type (String) - "lifespan.startup.complete".

Startup Failed - send event

Sent by the application when it has failed to complete its startup. If a server sees this it should log/print the message provided and then exit.

Keys:

-

type (String) - "lifespan.startup.failed".

-

message (String) - Optional; if missing defaults to "".

Shutdown - receive event

Sent to the application when the server has stopped accepting connections and closed all active connections.

Keys:

-

type (String) - "lifespan.shutdown".

Shutdown Complete - send event

Sent by the application when it has completed its cleanup. A server must wait for this message before terminating.

Keys:

-

type (String) - "lifespan.shutdown.complete".

Shutdown Failed - send event

Sent by the application when it has failed to complete its cleanup. If a server sees this it should log/print the message provided and then terminate.

Keys:

-

type (String) - "lifespan.shutdown.failed".

-

message (String) - Optional; if missing defaults to "".

Version History

-

0.3 (Draft): No changes from 0.2

-

0.2 (Draft): Added is_worker and worker_num fields for multi-worker support

-

0.1 (Draft): Initial lifespan specification

This document has been placed in the public domain.