Lifecycle¶
Phases¶
A phase is the name of one pass. The framework ships three:
They are both the decorators that mark hooks and the arguments to enter() and leave().
class Database(Canary):
@init
def prepare(self) -> None: ...
@start
async def connect(self) -> None: ...
@stop
async def close(self) -> None: ...
One class may have several hooks in one phase; they run in definition order. One method may
belong to several phases. Hooks may be synchronous or async def — the framework decides
whether to await by looking at the return value, so a synchronous function returning a
coroutine works too.
Entering: dependencies first¶
await unit.init() enters init across the graph: dependencies first, then the unit's own
hooks.
class Config(Canary): ...
class Database(Canary):
config = dep(Config)
class Service(Canary):
database = dep(Database)
await Service().init() # Config -> Database -> Service
Three properties:
- One unit runs one phase exactly once, no matter how many units depend on it.
- Independent units enter concurrently, so elapsed time tracks the graph's critical path rather than the sum of all units.
- Nothing runs until the graph checks out. The dependency graph is built first, so a cycle,
or a unit whose
@inithas not run beforestart(), is reported before any hook runs.
The barrier¶
The return of init() is a barrier: every @init completes before any @start runs.
Config.init -> Database.init -> Service.init
↓ barrier
Config.start -> Database.start -> Service.start
This is what actually separates @init from @start. @init acquires nothing external, so a
failure during its pass over the graph needs no reclamation; @start acquires, so it has a
matching @stop.
Calling start() without init() raises:
Stopping¶
A unit holds what @start acquired from the moment its @start begins, not when it
completes — so a unit that fails halfway is reclaimed too.
stop() is the single reclamation path. It stops the unit unless something still uses it, then
tries each of its dependencies the same way:
| When called | Behaviour |
|---|---|
| Never started | Nothing to stop; its dependencies are still tried |
Only init() ran |
Nothing entered @start, no-op |
| After a normal start | Stops the unit, then the dependencies nothing else uses |
| Still in use — a dependent is starting, running or stopping | Skips the unit, no error; its dependencies are still tried |
start() still running |
Waits for it to finish, then decides |
| Called again | Nothing left, no-op |
A dependency shared by several units stops once the last of them has stopped. See
Units › stop() is a unit action for examples.
Because stop() waits for an in-flight start(), do not call it from inside a @start hook of
the same graph: it would wait for itself. To bound shutdown, wrap the call in asyncio.timeout.
Starting again¶
Reclamation also undoes the record of start for each unit it reclaims, so a stopped graph
can start again. @init has no reclamation phase, so its record stays and it does not run a
second time:
A phase declared with after=start is undone along with start, and has to be entered
again after the restart.
A failed start() has already released what it acquired (see below), so retrying is simply
calling it again. Units that completed the phase are not run twice:
try:
await service.start()
except ConnectionError:
await service.start() # the failed attempt already cleaned up after itself
Failure¶
Startup failure. start() cleans up after itself:
- a unit whose
@startraises runs its own@stopat once; - units that depend on it do not start, and release the dependencies they were waiting on;
- units starting alongside it are not cancelled — they finish, then are released if nothing else uses them.
When start() raises, everything it brought up has been stopped, except what other running
units still use. With X → A, B, A → C, B → C and B failing:
Errors raised by @stop during this rollback are attached as notes on the original exception.
If the caller cancels start(), the same rollback runs before the cancellation propagates; a
cancellation cannot carry @stop errors, so they go to the event loop's exception handler.
Several units failing at once. A failure does not cancel other units. When several fail,
the failures are combined into one ExceptionGroup; a lone failure is re-raised as-is, and one
failure reached through several paths is reported once.
Reclamation failure. One failing @stop does not abort the pass; the remaining units are
reclaimed and everything is raised at the end as one ExceptionGroup, even for a single error:
ExceptionGroup: 1 error(s) while leaving @start
RuntimeError: could not close
raised by Database.close
Custom phases¶
Phase is public, and adding a phase requires no registration. Canary's methods are thin
wrappers over two functions — unit.start() is enter(unit, start), unit.stop() is
leave(unit, start) — so a phase of your own uses them directly:
from canary_framework import Phase, enter, init, leave
rollback = Phase("rollback")
migrate = Phase("migrate", after=init, leave=rollback)
class Schema(Canary):
@migrate
async def apply(self) -> None: ...
@rollback
async def revert(self) -> None: ...
await unit.init()
await enter(unit, migrate) # runs @migrate, dependencies first
await leave(unit, migrate) # runs @rollback, the unit first
after declares a predecessor: entering a phase whose predecessor has not been entered raises
LifecycleError instead of silently skipping it.
leave names the phase whose hooks run when this one is left, exactly as stop for start:
leave() runs them with the same rules as stop(), and a unit whose @migrate fails runs its
@rollback at once.
Hosting¶
The framework knows nothing about shells. When the host takes an async context manager:
When the host takes paired start/stop callbacks, hand it service.init, service.start and
service.stop directly.