onlycasual1
11/11/2025, 1:44 AMParent Flow (run_multiple_operations_flow)
└─> Batch Flow (process_operations_batch_flow) - timeout_seconds=36000
└─> 100x Subflow (process_single_operation_flow)
└─> Task (execute_operation_task) ← Tag-based concurrency applied here
### Concurrency Configuration
***Task Definition:***
python
@task(
_name_="Execute Operation Task",
_retries_=0,
_tags_=["my_operation_tag"], _# Tag-based concurrency control_
)
_async_ _def_ execute_operation_task(_config_: _dict_, _item_id_: _str_) -> _dict_:
result = await perform_long_running_operation(config, item_id) _# Takes ~5 minutes_
return result
***Prefect Concurrency Limit:***
bash
$ prefect concurrency-limit inspect my_operation_tag
Tag: my_operation_tag
Concurrency Limit: 1
***Batch Flow Code:***
python
@flow(
_name_="Process Operations Batch",
_log_prints_=True,
_timeout_seconds_=36000, _# 10 hours_
)
_async_ _def_ process_operations_batch_flow(
_items_: list[_dict_],
_run_name_: _str_ | None = None,
):
_# Create 100 subflow coroutines_
tasks = []
for item in items: _# 100 items_
task = process_single_operation_flow(_item_=item, _run_name_=run_name)
tasks.append(task)
_# Wait for all 100 to complete_
gathered_results = await asyncio.gather(*tasks, _return_exceptions_=True)
return results
## The Error
After running for ***~6 minutes***, we get:
Concurrency lease renewal failed - slots are no longer reserved.
Terminating execution to prevent over-allocation.
Crash detected! Execution was cancelled by the runtime environment.
Traceback:
File "/usr/local/lib/python3.13/site-packages/prefect/concurrency/_leases.py", line 135, in amaintain_concurrency_lease
yield
...
asyncio.exceptions.CancelledError: Cancelled via cancel scope
The above exception was the direct cause of:
prefect._internal.concurrency.cancellation.CancelledError
Finished in state Crashed('Execution was cancelled by the runtime environment.')
## Questions
1. ***Root Cause***: With tag-based concurrency on the task (not using a context manager), does the concurrency lease have a default TTL that's causing it to expire after ~6 minutes?
2. ***Tag-Based Concurrency Lease Duration***: When using tags=["igxe_web_registration"] on the task decorator, can we configure the lease duration? Or is there a hardcoded default?
3. ***Proper Solution***: What's the correct way to handle this scenario where:
- We want to run 100 registrations sequentially (concurrency=1)
- Each registration takes ~5 minutes
- Total runtime = ~500 minutes (8+ hours)
- We need the concurrency lease to stay valid throughout
4. ***Context Manager vs Tags***: Should we switch from tag-based concurrency to using async with concurrency("igxe_web_registration", occupy=1, lease_duration=timedelta(hours=2)) inside the task? Does that solve the lease renewal issue?
5. ***Parent Flow Timeout***: We added timeout_seconds=36000 to the batch flow. Does that help with lease renewal, or is that a separate concern?
6. ***Best Practice***: For long-running workflows with many sequential tasks (100 tasks, concurrency=1, 8+ hours total), what's the recommended Prefect pattern? Should we batch process (10 at a time) to give natural lease renewal points?
## What We Need
Clear guidance on:
- Whether tag-based concurrency has lease duration limits
- How to properly configure lease duration for our use case
- Whether we need to refactor from tags to context managers
- Best practices for long-running sequential workflows in Prefect
Thank you!Marvin
11/11/2025, 1:44 AMMarvin
11/11/2025, 1:52 AMfrom prefect import task
@task(name="Execute Operation Task", tags=["my_operation_tag"], retries=0)
def execute_operation_task(config: dict, item_id: str) -> dict:
# synchronous/blocking work is fine here; Prefect will run it in a thread pool
return perform_long_running_operation(config, item_id)
- Or offload blocking work from an async task:
from anyio import to_thread
from prefect import task
@task(name="Execute Operation Task", tags=["my_operation_tag"], retries=0)
async def execute_operation_task(config: dict, item_id: str) -> dict:
return await to_thread.run_sync(perform_long_running_operation, config, item_id)
This keeps the event loop free so the lease renewer can run.
4) Context manager vs tags
- The asyncio concurrency context manager does not expose a lease_duration parameter. Its signature is:
- names, occupy=1, timeout_seconds=None, max_retries=None, strict=False
- API reference
- Using the asyncio context manager will not fix event-loop starvation — it uses the same renewal mechanism and still needs the event loop to be responsive.
- If your work is blocking, using the sync context manager can help (renewal runs on a background thread), but you would typically choose one approach:
- Keep tag-based concurrency for global queuing across tasks/flows, OR
- Use the context manager if you want to control the critical section manually in code (but it won’t create a global queue across runs unless paired with limits of the same name everywhere).
- Don’t “double up” (tags + context manager) unless you really need both — it can be confusing and unnecessary.
5) Parent flow timeout
- Flow timeout_seconds is separate from concurrency leases. It won’t help or hurt lease renewal — it just caps the total run time of that flow.
6) Best practice for long-running sequential workloadsMarvin
11/11/2025, 1:52 AMperform_long_running_operation does (e.g., requests, Selenium, CPU-heavy), I can suggest the safest way to wrap it (thread vs process) to guarantee lease renewals.
More references:
- Global concurrency limits overview
- Lease-based slot acquisitiononlycasual1
11/11/2025, 4:34 PMMarvin
11/11/2025, 4:34 PMonlycasual1
11/11/2025, 4:38 PMMarvin
11/11/2025, 4:39 PMMarvin
11/11/2025, 4:44 PMprocess_single_item_flow(item) in a loop, you’ll submit one subflow at a time. That would negate the fan-out you were getting from asyncio.gather(...).
- If you need multiple flows to run concurrently, you must fan out to separate flow runs (via deployments), not by calling subflows directly.
2) Minimal async approach
- Given that ~95% of your work is blocking (Selenium/Appium, Django ORM), the simplest approach is to make your tasks synchronous and use .submit() for concurrency. Your flows can be sync too; .submit() is non-blocking in both sync and async flows.
- If you keep any async functions, do not run blocking code directly in them. Either:
- Make those tasks sync functions; or
- Offload the blocking calls with await anyio.to_thread.run_sync(...) (or Prefect’s run_sync_in_worker_thread(...)).
3) How Prefect handles sync task submission
- .submit() returns immediately with a PrefectFuture. The task is scheduled on the flow’s task runner. By default, Prefect uses a thread pool task runner so these tasks execute concurrently in separate threads.
- Concurrency is governed by:
- The task runner’s parallelism (e.g., ThreadPoolTaskRunner(max_workers=5)), and
- Any tag-based concurrency limits you’ve configured (which are enforced by the server).
- Only when you call future.result() (or wait()) do you block in the flow.
4) Recommended pattern for your use case
- Make your tasks synchronous.
- Keep the parent flow synchronous and use .submit() to fan out work.
- Configure a small thread pool on the flow (e.g., 1–5 workers), and let your tag-based concurrency limit throttle the effective rate across runs.
- Avoid async definitions for blocking Selenium/Appium code. If you must keep any async surface, offload blocking calls with anyio.to_thread.run_sync.
This avoids event loop starvation that led to concurrency lease renewal failures while still giving you non-blocking submission and concurrency at the task level.
Concrete examples
A) Recommended (simple, sync all the way)
from prefect import flow, task
from prefect.task_runners import ThreadPoolTaskRunner
@task(tags=["work_concurrency"]) # your tag limit stays in effect
def execute_work_task(item: dict):
# Blocking Selenium/Appium/Django code is fine here
return _execute_state_machine(item)
@flow(name="Process Batch", task_runner=ThreadPoolTaskRunner(max_workers=5))
def process_batch_flow(items: list[dict]):
futures = [execute_work_task.submit(item) for item in items] # Non-blocking submissions
results = [f.result() for f in futures] # Block here to collect results
return results
- .submit() returns immediately; up to 5 tasks will run concurrently in threads.
- Your tag-based concurrency limit (e.g., 1) will still throttle globally; tasks beyond the limit wait in orchestration until a slot is available.
- No event loop blocking = no lease renewal starvation.
B) If you must keep async in places, offload the blocking bits
from prefect import flow, task
import anyio
@task(tags=["work_concurrency"])
async def execute_work_task(item: dict):
# Offload blocking work so the event loop is not blocked
return await anyio.to_thread.run_sync(_execute_state_machine, item)
Or use Prefect’s helper (a thin wrapper around the same idea):
from prefect.utilities.asyncutils import run_sync_in_worker_thread
@task(tags=["work_concurrency"])
async def execute_work_task(item: dict):
return await run_sync_in_worker_thread(_execute_state_machine, item)
C) If you truly need concurrent flows (not just tasks)Marvin
11/11/2025, 4:44 PMmax_workers to match desired local parallelism (e.g., 1–5). Selenium drivers are not always thread-safe; ensure each task creates/uses its own driver instance and doesn’t share global driver state.
- Your tag-based concurrency limit will cap “in-flight” tasks with that tag across your workspace. With a limit of 1, tasks will run sequentially even if max_workers > 1, and .submit() remains non-blocking.
- If you want per-resource limits (e.g., one per site), use different tags with separate concurrency limits.
Docs for deeper reference
- Running work concurrently: Run work concurrently
- Task runners and defaults (ThreadPoolTaskRunner): Task runners
- Tag-based concurrency limits: Tag-based concurrency limits
- Futures semantics: Prefect futures
- Offloading blocking code from async: prefect.utilities.asyncutils
Clarifying questions
- Are you on Prefect 3.x? If not, I’ll tailor the guidance accordingly.
- What’s your desired effective parallelism (how many items truly at once), and what’s your current tag limit value?
- Do you need flow-level concurrency, or is task-level concurrency within a single flow run sufficient?onlycasual1
11/11/2025, 9:28 PMMarvin
11/11/2025, 9:28 PMMarvin
11/11/2025, 9:29 PMMarvin
11/11/2025, 9:29 PMUse when you want atomic behavior and fewer failure points, and your service is tolerant of whole-task retries.
Pattern B: Step-oriented tasks at side-effect boundaries (more observability, independent retries)
- Break out I/O-heavy steps that should retry independently. Keep pure logic inside tasks/services.
from prefect import flow, task
@task(retries=3, retry_delay_seconds=10)
def run_work(user_data, config):
return RegistrationService().run_workflow(user_data, config) # No internal DB writes
@task(retries=5, retry_delay_seconds=5)
def write_audit_record(result):
PersistenceService().create_audit_record(result) # idempotent
@task(retries=5, retry_delay_seconds=5)
def update_user_status(user_id, success):
PersistenceService().update_user_status(user_id, success) # idempotent
@flow
def process_registration_flow(user_data, config):
try:
result = run_work(user_data, config)
write_audit_record.submit(result)
update_user_status.submit(user_data.id, True)
return result
except Exception:
update_user_status.submit(user_data.id, False)
raise
```
Use when you need per-step policies and visibility, and you can make each step idempotent.
Concrete answers to your examples
- Simple DB update: If you need a different retry/backoff or separate visibility, use a task. If it belongs to a single transactional unit and should succeed/fail with the parent work, keep it in the parent task.
- External API call: Typically a good task with retries/backoff/timeout. If it must be atomic within a larger transaction and you’re confident in idempotency, keep it in the parent task and set retries there.
Operational tips
- Use task_runner=ThreadPoolTaskRunner(max_workers=N) at the flow level for concurrency; keep blocking code in sync tasks. See Task runners.
- Use tags and tag-based concurrency limits to throttle step types, not just whole flows: Tag-based concurrency limits.
- Prefer idempotent DB updates (upserts, natural keys) and idempotency keys for external calls.
- Use task_run_name to improve UI clarity for granular tasks.
- Use logs and artifacts inside tasks for observability even when you keep tasks coarse.
Related references
- Task runners and concurrency: Task runners
- Futures and submission semantics: Prefect futures
- Concurrency limits by tag: Tag-based concurrency limits
- Workflow design patterns (blog): Workflow design patterns
If you share which steps must be atomic together vs which can retry independently, I can suggest a concrete mapping of your current services into tasks with minimal churn.