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.startupon init andlifespan.shutdownon 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
versionkey of thepagihashref (accessed as$scope->{pagi}{version}) (String) - The version of the PAGI core spec that governs the worker. - -
-
The
spec_versionkey of thepagihashref (accessed as$scope->{pagi}{spec_version}) (String) - The version of this lifespan sub-specification. Optional; if missing, assume it matchesversion. - -
-
The
is_workerkey of thepagihashref (accessed as$scope->{pagi}{is_worker}) (Int, optional) -1when running as a worker in a multi-process deployment;0in 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_numkey of thepagihashref (accessed as$scope->{pagi}{worker_num}) (Int, optional) - Worker identifier (1, 2, 3, ...) in multi-process mode. Absent orundeffor single-process mode. Combined withis_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:
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:
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:
Shutdown Complete - send event
Sent by the application when it has completed its cleanup. A server must wait for this message before terminating.
Keys:
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
Copyright
This document has been placed in the public domain.