Lifecycle Extension Mechanisms¶
Future design—not an ETLantic 0.14 API guide
Parts of this chapter describe intended lifespan / middleware / resource injection surfaces that are not fully shipped. Prefer Callbacks, run reports, and the local runtime APIs documented in the API reference for 0.14 applications.
ETLantic provides four distinct mechanisms for inserting reusable behavior at well-defined points in modeling and execution:
- Runtime lifespan
- Execution middleware
- Resource injection
- Lifecycle callbacks and outbound event declarations
These mechanisms are inspired by FastAPI's separation of lifespan, middleware, dependencies, and OpenAPI callbacks or webhooks. ETLantic adopts the architectural distinction, not the HTTP terminology or request model.
Why Separate Mechanisms?¶
A single universal hook system would be convenient initially but difficult to reason about later.
Each mechanism answers a different question:
| Mechanism | Question |
|---|---|
| Lifespan | What must be initialized once and cleaned up once? |
| Middleware | What behavior wraps every selected run or step invocation? |
| Resource injection | What typed service does this callable require? |
| Callback | How should the framework respond to a lifecycle outcome? |
| Outbound event declaration | What external notification can this pipeline emit? |
The mechanisms should not be interchangeable.
1. Runtime Lifespan¶
A lifespan is an async context manager surrounding a runtime session.
from contextlib import asynccontextmanager
from etlantic import PipelineRuntime
@asynccontextmanager
async def lifespan(runtime: PipelineRuntime):
await warm_contract_cache()
yield
await flush_observability()
runtime = PipelineRuntime(lifespan=lifespan)
Code before yield runs before the runtime accepts pipeline runs. Code after
yield runs during shutdown after active work has completed or been cancelled.
Appropriate uses¶
- initialize shared plugin registries
- warm immutable metadata or contract caches
- open shared pools through providers
- start observability exporters
- verify environment health
- flush and close shared services
Scopes¶
ETLantic should support explicit scopes:
Runtime lifespan
Once for a PipelineRuntime instance.
Run lifespan
Once around one pipeline run.
Execution-region lifespan
Optional plugin-managed scope around a physical execution region.
Step-scoped resources normally use resource injection rather than a custom lifespan.
Composition¶
Multiple lifespan components should compose through an async exit stack. Initialization occurs in registration order; cleanup occurs in reverse order. Partially initialized lifespans must still clean up successfully acquired resources.
2. Execution Middleware¶
Middleware wraps a run or step invocation before and after the delegated operation:
async def timing_middleware(context, call_next):
started = monotonic()
try:
return await call_next(context)
finally:
context.metrics.observe(
"execution.duration",
monotonic() - started,
)
Registration is explicit:
Appropriate uses¶
- structured logging
- tracing
- timing and metrics
- policy enforcement
- context propagation
- bounded rate limiting
- exception normalization
Scopes¶
ETLantic should distinguish:
Run middleware
Wraps an entire pipeline run.
Step middleware
Wraps each logical step invocation.
Provider middleware
Optional SDK mechanism around provider acquisition.
Physical backend operations remain plugin concerns. Core middleware observes logical operations and must preserve their identities.
Ordering¶
Middleware forms a stack:
The ordering must be deterministic and visible through inspection.
Restrictions¶
Middleware must not:
- mutate the logical pipeline graph
- silently replace input or output contracts
- hide mandatory failures
- acquire undeclared resources
- change plan identity after planning
- consume or duplicate large artifacts merely for inspection
Middleware may reject an operation through a structured policy result. Transformation or artifact replacement requires an explicit, separately planned extension point.
3. Resource Injection¶
FastAPI calls this dependency injection. ETLantic uses the term resource injection to avoid confusion with edges in the pipeline dependency graph.
Transformation implementations and callbacks declare typed requirements:
from typing import Annotated
from etlantic import Inject, Resource
Warehouse = Annotated[SqlDatabase, Inject("warehouse")]
@NormalizeCustomers.implementation("python")
async def normalize_customers(
customers,
warehouse: Warehouse,
):
...
ETLantic resolves the requirement through the selected profile and resource provider.
Hierarchical providers¶
Resources may depend on other resources:
The resolver builds and validates a resource graph independently from the pipeline data-flow graph.
Yield-based cleanup¶
Provider functions may use yield or async context managers:
async def provide_session(engine: DatabaseEngine):
async with engine.session() as session:
yield session
The value is injected before invocation and cleaned up after invocation, including failure and cancellation paths.
Scopes¶
Caching occurs only within the declared scope. Retry behavior must not reuse a resource whose scope or safety policy forbids reuse.
Overrides¶
Tests and local debugging need explicit overrides:
Overrides are runtime configuration. They do not alter the portable pipeline contract.
4. Lifecycle Callbacks¶
Callbacks respond to specific outcomes:
@pipeline.on_step_failed
async def notify_failure(context: StepFailureContext) -> FailureAction:
return FailureAction.fail()
Callbacks differ from middleware:
- middleware wraps every matching operation
- callbacks run only for declared lifecycle events
- callbacks may return declarative action objects
- callbacks do not control arbitrary continuation
Common callback points include:
run_planned
run_started
run_completed
run_failed
step_started
step_completed
step_failed
validation_failed
artifact_materialized
state_committed
Callbacks are operational policy, not transformation logic.
5. Outbound Event Declarations¶
FastAPI's OpenAPI callbacks and webhooks primarily document requests an application may send to another system. The ETLantic equivalent is a typed outbound event declaration.
class CustomerPipeline(Pipeline):
customer_published = OutboundEvent[CustomerPublished](
event="customer.published",
delivery="customer_events",
)
An event may be emitted by a callback:
@CustomerPipeline.on_complete
def publish_completion(context):
return Emit(
CustomerPipeline.customer_published,
CustomerPublished(
pipeline_id=context.pipeline_id,
run_id=context.run_id,
),
)
The profile binds customer_events to an implementation:
What ETLantic owns¶
- event name and payload type
- lifecycle association
- delivery requirements
- documentation metadata
- planning and capability validation
- normalized delivery results
What providers own¶
- endpoint or topic resolution
- authentication
- serialization and transport
- retry and dead-letter behavior
- delivery receipts
Not a fourth contract family¶
Outbound events are pipeline interface metadata and runtime configuration. They do not create another top-level contract standard alongside ODCS, DTCS, and DPCS.
HTTP-specific webhook descriptions may be generated from outbound event declarations through a documentation plugin. The ETLantic core remains transport-neutral.
Selection Guide¶
Use:
- lifespan for once-per-scope setup and cleanup
- middleware for behavior surrounding every matching invocation
- resource injection for typed services required by a callable
- callbacks for outcome-specific operational policy
- outbound events for documented notifications to external consumers
Combined Example¶
runtime = PipelineRuntime(lifespan=project_lifespan)
runtime.add_run_middleware(logging_middleware)
runtime.add_step_middleware(tracing_middleware)
@NormalizeCustomers.implementation("python")
async def normalize(
customers,
warehouse: Annotated[SqlDatabase, Inject("warehouse")],
):
...
@CustomerPipeline.on_failure
async def report_failure(context):
return Emit(CustomerPipeline.pipeline_failed, context.to_event())
Every mechanism remains independently inspectable, testable, and configurable.
Key Principle¶
Setup and cleanup belong to lifespan. Cross-cutting wrappers belong to middleware. Required services belong to resource injection. Outcome policy belongs to callbacks. External notifications belong to typed outbound event declarations.