Skip to content

Declared warm-up plans

Warm-up plans declare singleton services to initialize during application startup. Build selects and validates their frozen targets without constructing them. The application explicitly runs one named plan after build. Successful initialization does not prove connectivity, health, readiness, or deferred provider acquisition.

from collections.abc import Iterator
from clean_ioc import ContainerBuilder, WarmupPlan, WarmupTarget

class Pool:
    pass

events = []
def pool() -> Iterator[Pool]:
    events.append("open")
    try:
        yield Pool()
    finally:
        events.append("close")

builder = ContainerBuilder()
builder.register(Pool, factory=pool, lifespan="singleton")
builder.add_warmup_plan(WarmupPlan("startup", [WarmupTarget(Pool)]))
with builder.build() as container:
    assert events == []
    container.warmup("startup").raise_for_errors()
    first = container.resolve(Pool)
    container.warmup("startup").raise_for_errors()
    assert container.resolve(Pool) is first
    assert events == ["open"]
assert events == ["open", "close"]

WarmupPlan captures the target iterable into an immutable ordered tuple. WarmupTarget requires a closed service key and an optional synchronous filter. The default filter selects unnamed registrations. Use WarmupTarget(Client, filter=cf.with_name("primary")) or an ID filter for named selection. Each target must select exactly one visible resolvable singleton after normal fallback and nearest overlay-layer selection. Multiple matching registrations in that layer are ambiguous. Collections, slots, dependency-only registrations, provider handles and non-singleton targets cannot be warmed directly.

Closed generic requests create explicit compilation demand, including structural patterns. Aliases, exposures, decorators, pre-configurations, return annotations and ordinary ownership rules apply. Targets are captured once; runtime never invokes selection filters. Repeated requests selecting the same singleton in one plan fail build. Sharing a target between distinct plans is legal.

ContainerBuilder and ScopeBuilder support declarations. Visible names must be nonempty and unique across inherited layers. After a failed build, remove_warmup_plan(name) removes local declarations before repairing the builder; it cannot remove inherited plans. Successful builders remain single use. An overlay recompiles inherited intent against its visible composition. Parent singletons retain parent ownership; overlay replacements belong to the overlay. Ordinary child scopes reuse the frozen selections.

Targets execute sequentially in declaration order, each with a fresh normal resolution context. Internal dependency concurrency and ordering remain unchanged. Ordinary exceptions are aggregated and later targets still execute. Successful resources remain owned and cached; failure reporting introduces no rollback. Failed initializers can retry on a later explicit run under ordinary singleton rules. Concurrent warm-up and ordinary resolution share the same coordinator.

from clean_ioc import ContainerBuilder, WarmupError, WarmupPlan, WarmupTarget

class Broken:
    pass
class Successful:
    pass

def fail():
    raise RuntimeError("private connection details")

builder = ContainerBuilder()
builder.register(Broken, factory=fail, lifespan="singleton")
builder.register(Successful, lifespan="singleton")
builder.add_warmup_plan(WarmupPlan("startup", [WarmupTarget(Broken), WarmupTarget(Successful)]))
try:
    with builder.build() as container:
        report = container.warmup("startup")
        assert [result.status for result in report.results] == ["failed", "succeeded"]
        assert "private connection details" not in report.to_json()
        report.raise_for_errors()
except WarmupError as error:
    assert error.report.name == "startup"
    # Escaping the owning context shuts down acquired resources normally.

Use await warmup_async(name) whenever activation or eager cleanup needs async. Sync warm-up preflights the whole plan and executes nothing if any target needs async. Every result then has not_executed status; async targets carry the warmup-requires-async finding. Async runs accept both sync and async targets.

import asyncio
from collections.abc import AsyncIterator
from clean_ioc import ContainerBuilder, WarmupPlan, WarmupTarget

class Client:
    pass

events = []
async def client() -> AsyncIterator[Client]:
    events.append("open")
    try:
        yield Client()
    finally:
        events.append("close")

async def main():
    builder = ContainerBuilder()
    builder.register(Client, factory=client, lifespan="singleton")
    builder.add_warmup_plan(WarmupPlan("startup", [WarmupTarget(Client)]))
    async with builder.build() as container:
        preflight = container.warmup("startup")
        assert not preflight.is_valid and events == []
        (await container.warmup_async("startup")).raise_for_errors()
        assert events == ["open"]
    assert events == ["open", "close"]

asyncio.run(main())

Cancellation, KeyboardInterrupt, and other control signals propagate and stop later attempts. Keep the relevant owners open while warming and using services; warm-up does not extend their lifetime. Unknown plan names raise KeyError; closed scopes raise ScopeClosedError before execution.

container.warmup_plans and container.graph.warmup_plans expose immutable intent records, selected semantic paths, sources and async requirements without activation. Default manifests and fingerprints exclude this declaration sidecar. A closed request that adds a specialization may change the compiled graph. Build, validation, CLI graph/check/activation commands, semantic diff and build matrices never execute plans. Warm-up intent does not mark entry points, so existing unreachable-component warnings may remain when a target is outside entry-point reachability. Static activation scenarios remain hypothetical.

WarmupReport is a detached immutable observation with plan name, matching full graph fingerprint, ordered target identities, paths, sources and outcomes. to_text(), to_json() and to_sarif() render deterministically. assert_valid() raises an assertion containing SARIF findings; raise_for_errors() raises WarmupError containing the safe report. Neither helper retries or closes owners. Failure messages are fixed and exception types qualified. Exception objects, messages, tracebacks, resources, configured values and runtime IDs are omitted. Reports do not infer nested throw sites or distinguish cached successes as “already warm”. The opt-in runtime profiler observes requests, activation, caches, waits and eventual cleanup through its existing identities and counters.