14 – Periodic Events

A periodic background event source, rooted in the lifespan scope. Every interval the source produces a "tick" and delivers it to whoever is currently listening. It shows how to model your own events as Futures and run long-lived background work correctly — without ever naming an event loop.

The key idea: an event-driven app is a tree of futures. The source lives in a Future::Selector held by the lifespan handler, which the server keeps alive for the whole life of the app — so it is a real branch of the tree. Nothing is pinned in a file-scoped variable, so nothing is silently dropped, and because the selector propagates failures, a crashing source surfaces (the server logs it) rather than vanishing.

Anti-pattern, for contrast: starting the source at file scope and keeping it alive in an our (or a bare my, which is worse — it is garbage-collected as soon as the app file finishes loading, dying with a cryptic "lost its returning future" warning). That is a future with no parent in the tree. Give it a parent instead: the lifespan scope.

The timer is a Future::IO->sleep, not an IO::Async timer, so the app does not assume any particular loop.

Routes

Two background sources, one selector. A fast ticker (every 2s) drives count, /next, and /stream; a slower heartbeat (every 5s) drives beats. Both run on a single Future::Selector, which multiplexes them and makes a failure in either surface rather than vanish. Adding a third source is one more $selector->add.

Sharing state with the background source

The source and the request handlers rendezvous through a small hub object stored once in $scope->{state} at startup. This is deliberate. Each request scope gets a shallow copy of the lifespan state: the top-level keys are private to that copy, but the values (object references) are shared. So we store one hub and reach it through that shared reference — we never replace a top-level state key (e.g. $state->{count}++ in a request would change only that request's copy and silently desync the rest). The hub encapsulates the rule: it owns its waiter list and only ever mutates it in place.

Quick Start

1. Start the server:

pagi-server --app examples/14-periodic-events/app.pl --port 5014

From an uninstalled PAGI-Server checkout, add -I /path/to/PAGI-Server/lib:

perl -I /path/to/PAGI-Server/lib /path/to/PAGI-Server/bin/pagi-server \
  --app examples/14-periodic-events/app.pl --port 5014

2. Demo with curl:

curl -s localhost:5014/ ; echo
# => {"count":1,"beats":0,"hint":"GET /next to wait for the next tick"}

time curl -s localhost:5014/next ; echo
# => {"tick":2}   (blocks up to ~2s, then wakes on the next tick)

curl -s localhost:5014/ ; echo
# => {"count":2,"beats":1,...}   (both sources advanced while you waited)

# Watch the live stream -- one line per tick, until you Ctrl-C:
curl -N localhost:5014/stream
# => {"tick":3}
# => {"tick":4}
# => {"tick":5}      (a new line every ~2s, driven by the background source)

Scope: one node, one process

For teaching, this example deliberately runs as a single process on a single node — that is exactly what makes the in-memory TickHub a valid place for the source and the requests to rendezvous.

The moment there is more than one process, an in-memory hub stops being shared:

To fan one event source out to every worker and every node, publish through an external broker instead of an in-memory hub — e.g. PAGI::Middleware::Channels with its Redis backend, which keeps the same subscribe/publish shape this example hand-rolls. See PAGI::EventLoops for the in-process pattern and where the broker takes over.

Spec References