Limiting DAG Fan-Out With Concurrency Groups

A fan-out has two widths and only one of them is usually configured. The first is how many tasks the orchestrator will run at once, which protects your workers; the second is how many of those tasks may touch a particular shared resource, which protects everybody else’s. Setting only the first is how a fan-out that is perfectly polite to its own memory limit takes down a national WFS endpoint at nine in the morning. Concurrency groups are the mechanism for the second, and they belong on the resource, not on the task.

When to Use This Pattern

  • The fan-out reads a shared external service — a WMS, a WFS, a geocoder, an object store with a request quota.
  • Several flows read the same resource, so a per-flow limit cannot bound the total.
  • A downstream database has a connection ceiling that is smaller than the fan-out width.
  • The graph’s width is decided at runtime from a footprint, which means it can be much larger than anyone tested with.

Complete Working Example

Two limits, declared separately, applied to the same task.

from __future__ import annotations

from prefect import flow, task, get_run_logger
from prefect.concurrency.sync import concurrency, rate_limit


@task(retries=2, retry_delay_seconds=[15, 60], timeout_seconds=900, tags=["tile"])
def build_tile(tile: Tile, source: SourceSpec, dest: Path) -> Path:
    # Limit 1 — the upstream service. Global across every flow that names this slot.
    with concurrency(f"source:{source.host}", occupy=1, timeout_seconds=1800):
        window = fetch_window(source, tile)

    # Limit 2 — the database. Held for the write only, not for the whole task,
    # because a connection blocked on a warp is a connection nobody else can use.
    warped = warp_in_memory(window, tile)
    with concurrency("postgis-writers", occupy=1):
        write_footprint(tile, warped.stats)

    warped.save(dest)
    return dest


@task(retries=3, retry_delay_seconds=[5, 20, 60])
def geocode(address: str) -> Point:
    # A rate limit, not a concurrency limit: the constraint is requests per second,
    # not simultaneous requests, and holding a slot would not express it.
    rate_limit("nominatim", occupy=1)
    return call_geocoder(address)


@flow(name="tile-build", timeout_seconds=7200)
def tile_build(footprint_wkt: str, zoom: int, source: SourceSpec, dest: Path) -> int:
    tiles = plan(footprint_wkt, zoom)
    get_run_logger().info("fan-out width: %d tiles", len(tiles))
    states = build_tile.map(tiles, source=source, dest=dest, return_state=True)
    return sum(s.is_completed() for s in states)

The limits themselves are declared once, outside the flow, so they are shared by everything that names them:

# Six simultaneous reads of this host, across every flow in the deployment.
prefect gcl create "source:geodata.example.gov" --limit 6 --slot-decay-per-second 0.2

# The database has 40 connections; the pipeline may have 12 of them.
prefect gcl create postgis-writers --limit 12

# A per-tag limit as a backstop, so a runaway manifest cannot schedule 40 000 at once.
prefect concurrency-limit create tile 64
Two gates, two different jobsFour hundred and twelve mapped tasks pass a worker limit of sixty-four, which protects the workers, and then a source slot limit of six, which protects the upstream service.412 tasksmappedtag limit 64protects theworkerssource slots 6protects thepublisherupstream6 in flightSetting only the first gate lets 64 simultaneous requests reach a service sized for a handful.Setting only the second wastes worker memory on 412 tasks queued inside a context manager.
The two numbers answer different questions and are rarely equal. Naming the slot after the host rather than the flow is what makes the second one global.

Where the slot is acquired matters as much as its size. In the example the source slot wraps only the fetch, and the database slot wraps only the write, with the expensive warp sitting outside both. Wrapping the whole task body in a single slot is the tempting simplification and it converts a concurrency limit into a throughput limit: with six slots and a ninety-second warp, the pipeline makes six requests every ninety seconds regardless of how fast the source can actually answer. Holding a slot for the shortest interval that preserves the guarantee is the difference between a limit that protects a service and one that throttles your own pipeline to its slowest stage.

Parameter & Option Reference

Setting Typical Spatial notes
Tag limit (tile) 32–128 Bounds total in-flight tile tasks. Size it from worker memory ÷ per-tile peak.
Source slot 2–8 Per upstream host. Public agency endpoints are often sized for interactive use; start low.
Database slot ≤ ⅓ of pool Leave headroom for the application and for migrations. A pipeline that owns every connection blocks its own dashboards.
slot-decay-per-second 0.1–0.5 Turns a hard slot into a leaky bucket, which suits sources that dislike bursts more than volume.
rate_limit per second The right tool when the quota is requests-per-interval rather than simultaneity — geocoders, most commercial APIs.
timeout_seconds on the slot ≥ 2× stage Waiting for a slot must not itself become an unbounded wait; a raise is better than a hang.
Where a public endpoint stops answeringError rate stays near zero up to six concurrent requests, climbs from twelve, and passes half of all requests at thirty-two. The limit is set below the knee, not at it.errors16122432limit set here, at 6not at 12, where the curve turns:a retry storm adds load exactly whenthe service is least able to absorb it.Measure this once against the real endpoint at a quiet hour; it is a property of their capacity, not of your pipeline.
Setting the limit at the knee guarantees the pipeline lives at the edge of failure, where retries push it over.

Verification & Testing

The property to test is that the limit is honoured under load, which needs a counter rather than an assertion on configuration.

import threading

from prefect.testing.utilities import prefect_test_harness


def test_source_slot_is_respected(monkeypatch) -> None:
    live, peak, lock = 0, 0, threading.Lock()

    def fake_fetch(source, tile):
        nonlocal live, peak
        with lock:
            live += 1
            peak = max(peak, live)
        time.sleep(0.05)
        with lock:
            live -= 1
        return object()

    monkeypatch.setattr("mymodule.fetch_window", fake_fetch)
    with prefect_test_harness():
        tile_build(SERVICE_AREA_WKT, zoom=12, source=SPEC, dest=Path("/tmp"))
    assert peak <= 6, f"source limit breached: {peak} concurrent reads"


def test_slot_is_not_held_across_the_warp(monkeypatch) -> None:
    held: list[float] = []
    monkeypatch.setattr("mymodule.warp_in_memory", lambda *a: time.sleep(0.2))
    ...
    assert max(held) < 0.1, "slot held for the whole task, not just the fetch"

In production the number to watch is not the limit but the wait for it. A queue that is always empty means the limit is not binding and something else is the bottleneck; a queue that grows without bound means the limit is below the pipeline’s required throughput and the run will not finish in its window. The healthy signal is a short, non-zero wait — the limit is doing work and the pipeline is still moving.

Reading the slot-wait metricAn always-empty queue means the limit is not binding. A short steady wait is healthy. A growing queue means the limit is below the throughput the run needs.always zerolimit is not bindinglook elsewhereshort and steadyhealthy: the limit worksand the run still movesgrowinglimit is below thethroughput neededThe third case is the one that quietly misses a nightly window: nothing errors, and the run simply does not finish.
Slot wait is the cheapest capacity signal a pipeline has, and it needs no instrumentation beyond what the orchestrator already records.

Common Pitfalls

  • One limit for both jobs. Worker capacity and upstream capacity are unrelated numbers; a single figure is wrong for at least one of them.
  • Naming the slot after the flow. Two flows reading the same host then get one limit each, and the host gets both.
  • Holding the slot for the whole task. The limit becomes a throughput cap set by the slowest stage rather than a bound on simultaneous requests.
  • Setting the limit at the measured knee. Retries add load precisely when the service is degraded, so the limit must sit below the point where errors begin.
  • Using a concurrency limit where a rate limit is meant. Six simultaneous requests and six requests per second are different constraints, and geocoders almost always mean the second.
  • No timeout on slot acquisition. A jammed slot then turns a bounded fan-out into a flow that hangs until its own timeout, with nothing in the logs explaining why.

Frequently Asked Questions

How do I choose the source limit without load-testing someone's server?

Start at two, watch the error rate and the p95 latency for a full run, and raise it one step at a time. Most public endpoints degrade gracefully enough that this is safe, and the number you converge on is usually smaller than expected. Where a published quota exists, take it and subtract a margin for whoever else is using the same key.

Does the circuit breaker not make this unnecessary?

They solve adjacent problems. A concurrency group stops you causing the failure; a breaker stops you compounding one that has already happened. Both are worth having, and the breaker is much less likely to trip once the fan-out is bounded. See circuit breakers for external WMS services.

What about Dagster?

The same two-limit structure, expressed as pools on the asset and a run-level concurrency setting. Mapping tasks over a tile grid in Dagster shows a pool in place, and the reasoning about where to acquire the slot carries over unchanged.

Where should the limits be defined — in code or in the deployment?

In the deployment, as named server-side objects, with the code referring to them by name. A limit that lives in a decorator is a limit that has to be redeployed to change, which is the wrong property for a number you will want to lower during an incident and raise afterwards. It also makes the limit local to one flow by construction, and the whole value of a source slot is that it is shared by every flow reading that host.

What happens to the tasks that are waiting?

They occupy a worker slot while they block, which is why the tag limit belongs above the resource limit rather than below it. With sixty-four tasks admitted and six source slots, fifty-eight tasks sit inside a context manager holding whatever memory they have already allocated. Keeping the acquisition early in the task body — before any large allocation — is what stops a waiting queue from becoming a memory problem of its own.

Should the limit vary by time of day?

Occasionally, and it is worth the complexity only when the upstream is shared with interactive users — a municipal WFS that serves a public map viewer during office hours, say. A schedule that raises the slot count overnight is a two-line change and can double a nightly window’s throughput without ever affecting a person.

DAG Design Principles for Spatial ETL