Source code for airflow.example_dags.example_task_state_store_async
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""Example DAG for an async task that checkpoints its progress with ``aget``/``aset``.
The task fetches pages concurrently and records which ones finished. A staged failure
part way through the first attempt shows the retry resuming from the checkpoint instead
of re-fetching everything, and the Dag run still ends successfully.
"""
from __future__ import annotations
import asyncio
import logging
from datetime import datetime
from airflow.sdk import DAG, task
from airflow.sdk.execution_time.context import NEVER_EXPIRE
[docs]
log = logging.getLogger(__name__)
[docs]
PAGES = list(range(1, 13))
[docs]
BATCH_SIZE = 4 # pages awaited concurrently before each checkpoint
[docs]
CRASH_AFTER = 6 # staged failure, first attempt only
async def _fetch_page(page: int) -> int:
"""Placeholder for an awaited API call; returns the row count for the page."""
await asyncio.sleep(0.2)
return page * 100
with DAG(
dag_id="example_task_state_store_async",
schedule=None,
start_date=datetime(2026, 1, 1),
catchup=False,
tags=["example", "task-state-store"],
doc_md=__doc__,
) as dag:
@task(retries=2, retry_delay=5)
[docs]
async def ingest_pages(task_state_store=None, ti=None) -> dict:
"""Fetch every page, checkpointing after each concurrent batch."""
# Progress and the running total live in one key so a single write keeps
# them consistent with each other.
progress = await task_state_store.aget("progress", default={"done": [], "rows": 0})
done = set(progress["done"])
rows = progress["rows"]
if done:
log.info("Resuming: %d of %d pages already fetched", len(done), len(PAGES))
else:
log.info("Starting from the top: %d pages to fetch", len(PAGES))
remaining = [page for page in PAGES if page not in done]
for start in range(0, len(remaining), BATCH_SIZE):
batch = remaining[start : start + BATCH_SIZE]
rows += sum(await asyncio.gather(*(_fetch_page(page) for page in batch)))
done.update(batch)
# Only this coroutine writes the checkpoint. If each _fetch_page wrote its
# own, the writes would interleave at their await points, each overwriting a
# stale copy of the set, and finished pages would vanish from the checkpoint.
await task_state_store.aset(
"progress",
{"done": sorted(done), "rows": rows},
retention=NEVER_EXPIRE,
)
log.info("Checkpointed %d/%d pages after batch %s", len(done), len(PAGES), batch)
if CRASH_AFTER and ti.try_number == 1 and len(done) >= CRASH_AFTER:
raise RuntimeError(
f"Staged worker loss after {len(done)} pages. The retry picks up from the checkpoint."
)
log.info("All %d pages fetched, %d rows total", len(done), rows)
return {"pages": len(done), "rows": rows}
ingest_pages()