Async Pipelines¶
Future design—not a ETLantic 0.14 API guide
This page is a design study. It may describe packages, commands, or
interfaces beyond the shipped API surface. Prefer Current Capabilities,
the runnable examples under examples/, the API reference, and the CLI
reference for installable behavior.
This example demonstrates how ETLantic coordinates asynchronous and synchronous work within one validated pipeline.
Async execution is especially useful for I/O-bound operations such as:
- HTTP APIs
- Object storage
- Databases
- Message brokers
- Remote services
- Secret providers
- Notifications
- Concurrent independent branches
ETLantic allows pipeline authors to combine async def and ordinary
def implementations without manually managing event loops, thread pools, or
callback scheduling.
Goal¶
Build a pipeline that:
- Reads customer records from an asynchronous HTTP source.
- Reads account metadata from an asynchronous database source.
- Joins and normalizes the datasets with a synchronous Polars transformation.
- Publishes the curated result through an asynchronous sink.
- Invokes synchronous and asynchronous callbacks.
- Executes independent I/O operations concurrently.
- Preserves typed contracts, retries, lineage, and diagnostics.
- Generates ODCS, DTCS, and DPCS artifacts.
Architecture¶
Async HTTP Source ───────┐
├──► Sync Polars Transformation ───► Async SQL Sink
Async Database Source ───┘
The execution model may coordinate:
await HTTP read
await database read
│
▼
run synchronous transformation safely
│
▼
await output validation and write
Project Structure¶
async-pipelines/
├── pyproject.toml
├── src/
│ └── async_pipelines/
│ ├── __init__.py
│ ├── contracts.py
│ ├── transformations.py
│ ├── implementations.py
│ ├── callbacks.py
│ ├── pipeline.py
│ └── profiles.py
├── contracts/
├── docs/
└── tests/
├── test_async_pipeline.py
├── test_concurrency.py
└── test_mixed_sync_async.py
Step 1 — Define the Data Contracts¶
# src/async_pipelines/contracts.py
from typing import Annotated
from pydantic import Field
from etlantic import DataContractModel
class CustomerApiRecord(DataContractModel):
customer_id: Annotated[int, Field(strict=True, gt=0)]
first_name: str
last_name: str
email: str
class CustomerAccount(DataContractModel):
customer_id: Annotated[int, Field(strict=True, gt=0)]
account_status: str
account_tier: str
class CustomerProfile(DataContractModel):
customer_id: Annotated[int, Field(strict=True, gt=0)]
full_name: str
email: str
account_status: str
account_tier: str
The contracts remain independent of HTTP clients, async database drivers, Polars, and the destination database.
Step 2 — Define the Transformation Contract¶
# src/async_pipelines/transformations.py
from etlantic import Input, Output, Transformation
from .contracts import (
CustomerAccount,
CustomerApiRecord,
CustomerProfile,
)
class BuildCustomerProfiles(Transformation):
customers: Input[CustomerApiRecord]
accounts: Input[CustomerAccount]
result: Output[CustomerProfile]
The transformation interface does not declare whether its implementation is sync or async.
Step 3 — Add a Synchronous Polars Implementation¶
# src/async_pipelines/implementations.py
import polars as pl
from .transformations import BuildCustomerProfiles
@BuildCustomerProfiles.implementation("polars")
def build_customer_profiles(
customers: pl.LazyFrame,
accounts: pl.LazyFrame,
) -> pl.LazyFrame:
normalized_customers = customers.select(
pl.col("customer_id"),
pl.concat_str(
[
pl.col("first_name").str.strip_chars(),
pl.col("last_name").str.strip_chars(),
],
separator=" ",
).alias("full_name"),
pl.col("email")
.str.strip_chars()
.str.to_lowercase()
.alias("email"),
)
return (
normalized_customers
.join(
accounts,
on="customer_id",
how="inner",
)
.select(
"customer_id",
"full_name",
"email",
"account_status",
"account_tier",
)
)
The transformation remains synchronous and CPU-bound.
ETLantic coordinates it safely inside the async pipeline.
Step 4 — Define Async Source Implementations¶
Conceptually, the source plugins expose async reads.
from etlantic.sources import SourceReadContext
async def read_customer_api(
context: SourceReadContext,
):
response = await context.http.get(
"/customers",
)
response.raise_for_status()
return response.json()
An async database source may use an async driver:
async def read_customer_accounts(
context: SourceReadContext,
):
return await context.database.fetch_all(
'''
SELECT
customer_id,
account_status,
account_tier
FROM customer_accounts
'''
)
Source behavior belongs in plugins and bindings rather than the pipeline class.
Step 5 — Define Async and Sync Callbacks¶
# src/async_pipelines/callbacks.py
from etlantic.callbacks import (
PipelineFailureContext,
PipelineSuccessContext,
RetryContext,
)
def log_retry(
context: RetryContext,
) -> None:
print(
f"Retrying {context.step_id}: "
f"attempt {context.next_attempt}"
)
async def report_success(
context: PipelineSuccessContext,
) -> None:
await context.metrics.publish(
"customer-profile-pipeline.completed",
tags={
"pipeline": context.pipeline_id,
},
)
async def report_failure(
context: PipelineFailureContext,
) -> None:
await context.notifications.send(
channel="data-operations",
message=(
f"Pipeline {context.pipeline_id} failed "
f"with diagnostic {context.diagnostic_code}"
),
)
ETLantic invokes each callback according to its declaration style.
Step 6 — Define the Pipeline¶
# src/async_pipelines/pipeline.py
from etlantic import Pipeline, Sink, Source
from .callbacks import (
log_retry,
report_failure,
report_success,
)
from .contracts import (
CustomerAccount,
CustomerApiRecord,
CustomerProfile,
)
from .transformations import BuildCustomerProfiles
class CustomerProfilePipeline(Pipeline):
customers: Extract[CustomerApiRecord] = Extract(
asset="customer_api",
on_retry=log_retry,
)
accounts: Extract[CustomerAccount] = Extract(
asset="customer_accounts",
on_retry=log_retry,
)
profiles = BuildCustomerProfiles.step(
customers=customers,
accounts=accounts,
)
output: Load[CustomerProfile] = Load(
input=profiles.result,
asset="customer_profiles",
on_retry=log_retry,
)
callbacks = {
"on_success": report_success,
"on_failure": report_failure,
}
The pipeline topology remains identical whether execution is synchronous or asynchronous.
Step 7 — Define the Async Profile¶
# src/async_pipelines/profiles.py
from etlantic import Profile
production = Profile(
name="production",
orchestrator="local-python",
dataframe_engine="polars",
execution={
"mode": "async",
"maximum_concurrency": 8,
"maximum_attempts": 3,
"retry_delay_seconds": 2,
},
assets={
"customer_api": {
"plugin": "http-json",
"resource": "customer_service",
"path": "/customers",
},
"customer_accounts": {
"plugin": "async-postgresql",
"resource": "account_database",
"query": '''
SELECT
customer_id,
account_status,
account_tier
FROM customer_accounts
''',
},
"customer_profiles": {
"plugin": "async-postgresql",
"resource": "analytics_database",
"schema": "curated",
"table": "customer_profiles",
"write_mode": "replace",
},
},
resources={
"customer_service": {
"provider": "async-http",
"base_url": "https://customer.internal",
},
"account_database": {
"provider": "async-postgresql",
"credential": "account-database-access",
},
"analytics_database": {
"provider": "async-postgresql",
"credential": "analytics-database-access",
},
"metrics": {
"provider": "async-metrics",
},
"notifications": {
"provider": "async-notifications",
},
},
)
Credentials remain in external secret providers.
Step 8 — Validate the Pipeline¶
from async_pipelines.pipeline import CustomerProfilePipeline
report = CustomerProfilePipeline.validate()
report.raise_for_errors()
Definition validation should verify:
- Source and sink contracts
- Transformation compatibility
- Graph integrity
- Callback signatures
- Stable step identities
Step 9 — Validate the Profile¶
from async_pipelines.pipeline import CustomerProfilePipeline
from async_pipelines.profiles import production
profile_report = CustomerProfilePipeline.validate_profile(
production,
)
profile_report.raise_for_errors()
Capability validation should verify:
- Both source plugins support async reads.
- The sink plugin supports async writes.
- The Polars implementation exists.
- Sync transformation invocation is supported in async mode.
- Callback resources resolve.
- The concurrency limit is valid.
- Retry and timeout semantics can be preserved.
Step 10 — Build the Pipeline Plan¶
The plan should expose execution styles:
customer_api:
- async source read
customer_accounts:
- async source read
build_customer_profiles:
- synchronous Polars transformation
customer_profiles:
- async sink write
Concurrent Source Reads¶
The two source reads are independent.
ETLantic may execute them concurrently:
┌───────────────────┐
│ await customer API│
└───────────────────┘
│
├── concurrent
│
┌───────────────────────┐
│ await account database│
└───────────────────────┘
│
▼
both inputs available
This reduces total I/O wait time.
Dependency-Aware Concurrency¶
Concurrency is allowed only when graph dependencies permit it.
A and B may run concurrently.
C waits for both.
Step 11 — Execute Asynchronously¶
arun() is the primary API for async-aware applications.
Synchronous Entry Point¶
A synchronous application may still use:
ETLantic may manage the event loop internally when safe.
It should reject nested-loop misuse rather than attempting unsafe behavior.
Event Loop Ownership¶
The framework should distinguish:
- No event loop is running.
- A user-owned event loop is running.
- An orchestrator owns the loop.
- A notebook owns the loop.
- A remote worker owns the loop.
arun() is preferred when the caller already runs asynchronously.
Sync Transformation Invocation¶
The Polars transformation is synchronous.
ETLantic may invoke it:
- Directly, when it is short and non-blocking for the execution context.
- Through a worker thread.
- Through a process worker.
- Through an execution plugin-specific strategy.
The selected strategy should be visible in the plan.
Blocking I/O in Sync Functions¶
A synchronous function that performs blocking network or database I/O should not run directly on the event-loop thread.
Bad:
Prefer an async source plugin or an explicitly isolated sync implementation.
CPU-Bound Work¶
CPU-heavy synchronous work may block one async worker.
The execution profile may select:
Possible strategies include:
- Inline
- Thread
- Process
- Backend-managed worker
The strategy affects execution, not transformation semantics.
Async Transformation Implementations¶
A transformation may also be async:
ETLantic awaits it directly.
Async transformations are most appropriate for I/O-bound enrichment rather than dataframe computation.
Async Generators¶
Future plugins may support async iterators for streaming bounded data:
Batch and backpressure semantics must be explicit before selection.
Resource Lifecycle¶
Async Resource Providers may use async context management.
Conceptually:
ETLantic should guarantee release on:
- Success
- Failure
- Cancellation
- Retry
- Timeout
Step-Scoped Resources¶
Some resources should live only for one step:
- HTTP request session
- Database transaction
- Temporary file handle
Run-Scoped Resources¶
Others may live for the entire pipeline run:
- Shared HTTP client
- Connection pool
- Metrics client
- Notification client
The provider declares lifecycle scope.
Async Context Managers¶
A provider may implement:
The execution layer handles these lifecycle methods automatically.
Connection Pools¶
Async database providers should use bounded pools.
Profile settings may include:
Pool capacity should align with pipeline concurrency.
Concurrency Limits¶
The profile may define:
ETLantic should apply limits to prevent:
- API overload
- Database exhaustion
- Excessive memory use
- Too many open files
- Rate-limit violations
Per-Resource Limits¶
Resource Providers may expose stricter limits.
For example:
HTTP client: 8 concurrent requests
Database pool: 4 concurrent connections
Sink: 1 publication at a time
The scheduler should respect all active constraints.
Backpressure¶
When producers are faster than consumers, ETLantic should avoid unbounded buffering.
Possible strategies include:
- Bounded queues
- Batch limits
- Source throttling
- Concurrency reduction
- Streaming checkpoints
Backpressure semantics should be plugin-defined.
Timeouts¶
Profiles may configure:
- Source timeout
- Transformation timeout
- Sink timeout
- Callback timeout
- Pipeline timeout
Conceptually:
Timeouts should produce structured diagnostics and trigger cleanup.
Cancellation¶
Cancellation should propagate through:
- Pending async tasks
- HTTP requests
- Database operations
- Sink writes
- Callbacks
- Resource providers
Sync work running in a thread may not stop immediately.
The plan should document cancellation limitations.
Structured Concurrency¶
ETLantic should prefer structured concurrency principles:
- Child tasks belong to a pipeline or step scope.
- Failures are collected predictably.
- Cancellation propagates downward.
- Resources close when the scope exits.
- No orphan background tasks remain.
Failure Propagation¶
Suppose the account source fails while the customer API succeeds.
The planner should follow the declared policy:
customer API succeeds
account database fails
│
▼
cancel or retain sibling result
│
▼
retry or fail source step
Successful sibling work may be reused only when the retry and resource semantics permit it.
Retries¶
Async retries should not use blocking time.sleep().
The execution layer uses asynchronous delay.
Callbacks should observe retries rather than sleep manually.
Retry Classification¶
Potentially retryable async failures include:
- Connection reset
- Timeout
- Rate limiting
- Temporary DNS failure
- Database serialization error
- Service unavailable
Non-retryable failures often include:
- Invalid schema
- Contract incompatibility
- Authentication failure
- Missing permission
- Invalid query
Retry Jitter¶
Profiles may configure jitter to avoid synchronized retries.
Conceptually:
Retry configuration belongs in execution policy.
Idempotency¶
Async sink retries must consider idempotency.
For example:
- Replace through staging may be safe.
- Append may duplicate records.
- Upsert may be safe with stable keys.
- HTTP publication may require an idempotency key.
The execution plan should record retry safety.
Callback Execution¶
Sync callbacks may run inline or through the configured sync strategy.
Async callbacks are awaited.
A callback failure follows the configured callback failure policy.
Callback Concurrency¶
Independent callbacks may run concurrently when:
- Their events are independent.
- No ordering guarantee is required.
- Resource limits allow it.
Pipeline outcome callbacks run after the terminal state is determined.
Validation¶
Validation may include both sync and async work.
Examples:
- Sync dataframe schema validation
- Async registry lookup
- Async remote contract resolution
- Async sink compatibility check
Definition validation should remain executable without runtime I/O where possible.
Planning vs. Runtime I/O¶
Planning should avoid unnecessary remote calls.
Capabilities and metadata may be loaded from cached or provider-declared information.
Runtime acquisition performs actual network and database connections.
Materialization¶
The HTTP and database sources materialize bounded datasets before the Polars join.
For very large inputs, use SQL, PySpark, chunked processing, or streaming instead.
Async Does Not Mean Distributed¶
Async execution improves I/O concurrency.
It does not automatically provide:
- Parallel CPU execution
- Distributed memory
- Cluster execution
- Large-scale data processing
Choose PySpark or another distributed backend when required.
Observability¶
The runtime may emit:
- Task scheduled
- Task waiting
- Task started
- Task completed
- Concurrency slot acquired
- Resource acquired
- Resource released
- Retry delayed
- Cancellation requested
- Timeout
- Callback awaited
Diagnostics¶
An async diagnostic should identify:
- Pipeline
- Step
- Execution style
- Task identity
- Resource
- Timeout
- Cancellation status
- Attempt
- Failure category
- Suggested remediation
Example:
PMASYNC302
Pipeline: customer-profile-pipeline
Step: customer-accounts
Execution: async source read
The database operation exceeded its 30-second timeout.
The connection was cancelled and returned to the pool.
Attempt 2 of 3 will begin after asynchronous backoff.
Lineage¶
Logical lineage remains:
Runtime lineage may add:
- API endpoint identity
- Database source table
- Request or query identifiers
- Sink table
- Execution task IDs
- Materialization references
Step 12 — Generate Contracts¶
Expected output:
contracts/
├── data/
│ ├── customer-api-record.odcs.yaml
│ ├── customer-account.odcs.yaml
│ └── customer-profile.odcs.yaml
├── transformations/
│ └── build-customer-profiles.dtcs.yaml
└── pipelines/
└── customer-profile-pipeline.dpcs.yaml
Sync or async implementation style should not redefine the logical contracts.
Step 13 — Generate Documentation¶
Profile-aware documentation may include:
- Async source steps
- Sync transformation step
- Async sink
- Concurrency opportunities
- Resource limits
- Timeout policies
- Retry behavior
- Callback execution style
- Cancellation support
Step 14 — Generate Mermaid¶
Example:
flowchart LR
api["Async HTTP: CustomerApiRecord"]
db["Async SQL: CustomerAccount"]
transform["Polars: BuildCustomerProfiles"]
sink["Async SQL: CustomerProfile"]
api --> transform
db --> transform
transform --> sink
Testing the Async Pipeline¶
import pytest
@pytest.mark.asyncio
async def test_async_pipeline(
async_test_profile,
) -> None:
result = await CustomerProfilePipeline.arun(
profile=async_test_profile,
)
assert result.success
Testing Concurrent Sources¶
import asyncio
import time
import pytest
@pytest.mark.asyncio
async def test_sources_run_concurrently(
delayed_source_profile,
) -> None:
started = time.perf_counter()
await CustomerProfilePipeline.arun(
profile=delayed_source_profile,
)
duration = time.perf_counter() - started
assert duration < 1.8
If both sources each take one second and are independent, total time should be closer to one second than two.
Timing tests should use generous bounds and deterministic fake providers.
Testing Mixed Sync and Async¶
@pytest.mark.asyncio
async def test_sync_transformation_inside_async_pipeline(
mixed_profile,
execution_recorder,
) -> None:
await CustomerProfilePipeline.arun(
profile=mixed_profile,
)
assert execution_recorder.style(
"build-customer-profiles"
) == "sync-worker"
Testing Cancellation¶
@pytest.mark.asyncio
async def test_pipeline_cancellation(
slow_profile,
) -> None:
task = asyncio.create_task(
CustomerProfilePipeline.arun(
profile=slow_profile,
)
)
await asyncio.sleep(0.05)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
The test providers should verify that acquired resources were released.
Testing Retry Without Blocking¶
@pytest.mark.asyncio
async def test_async_retry(
transient_failure_profile,
retry_recorder,
) -> None:
result = await CustomerProfilePipeline.arun(
profile=transient_failure_profile,
)
assert result.success
assert retry_recorder.attempts == 2
Testing Resource Cleanup¶
@pytest.mark.asyncio
async def test_resources_close_on_failure(
failing_profile,
resource_recorder,
) -> None:
await CustomerProfilePipeline.arun(
profile=failing_profile,
raise_on_failure=False,
)
assert resource_recorder.all_released
Notebook Environments¶
Notebook environments often already run an event loop.
Use:
Do not wrap it with asyncio.run().
Web Applications¶
Inside FastAPI or another async framework:
@app.post("/run-pipeline")
async def run_pipeline():
result = await CustomerProfilePipeline.arun(
profile=production,
)
return result.summary()
Long-running pipelines should normally use a job system rather than remain tied to one HTTP request.
Airflow Integration¶
Airflow may coordinate an async-capable pipeline through:
- A synchronous task that owns the run loop
- Deferrable operators
- Remote execution
- An async-aware plugin mechanism
The Airflow plugin determines the correct mapping.
Async Source Fan-Out¶
A pipeline may fetch multiple independent APIs concurrently.
The profile should enforce:
- Global concurrency limit
- Per-host limit
- Rate limits
- Retry policy
- Timeout
- Circuit breaker where supported
Circuit Breakers¶
Resource plugins may support circuit breakers for failing remote services.
Circuit-breaker state is operational and should not become pipeline semantics.
Rate Limiting¶
An async HTTP provider may implement token-bucket or leaky-bucket limits.
The execution planner should respect provider-declared throughput constraints.
Bounded Queues¶
For batched or streaming async workflows, bounded queues can prevent unbounded memory growth.
Queue size is execution configuration.
Security¶
Async execution should enforce:
- External credentials
- Redacted errors
- TLS verification
- Connection cleanup
- Controlled cancellation
- Safe callback resources
- No task leakage
- No secrets in task names or diagnostics
Best Practices¶
- Use
arun()in async applications. - Keep I/O in async plugins and resources.
- Keep CPU-heavy work out of the event-loop thread.
- Let ETLantic coordinate sync and async implementations.
- Use bounded concurrency.
- Configure timeouts and retries explicitly.
- Preserve resource cleanup on every exit path.
- Use typed callback contexts.
- Make backend transitions visible.
- Test concurrency with deterministic fakes.
- Treat async as I/O concurrency, not distributed computing.
Anti-Patterns¶
Avoid:
- Calling
asyncio.run()inside a running event loop. - Using blocking HTTP or database clients in async steps.
- Calling
time.sleep()in async callbacks. - Starting orphan background tasks.
- Creating unbounded task fan-out.
- Sharing one mutable client without provider guarantees.
- Retrying non-idempotent writes blindly.
- Running CPU-heavy transforms directly on the event loop.
- Assuming async means parallel CPU execution.
- Skipping cancellation and cleanup tests.
Key Principle¶
Async pipelines let ETLantic overlap independent I/O while safely coordinating synchronous transformations, typed resources, callbacks, retries, timeouts, cancellation, validation, and lineage through one execution model.
Next Step¶
Continue with Contract-First to build a pipeline from portable ODCS, DTCS, and DPCS artifacts.