<@ULVA73B9P> # Prefect Concurrency Lease Renewal F...
# ask-marvin
o
@Marvin # Prefect Concurrency Lease Renewal Failure - Need Expert Advice ## Current Setup We have a Prefect workflow with the following architecture: ### Flow Hierarchy
Copy code
Parent 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:***
Copy code
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:***
Copy code
bash
$ prefect concurrency-limit inspect my_operation_tag
Tag: my_operation_tag
Concurrency Limit: 1
***Batch Flow Code:***
Copy 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:
Copy code
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!
m
thought for 430.8 seconds
Thanks for the detailed breakdown — this is super helpful. I’ll answer each question directly, then give a recommended pattern. Assumptions: You’re on Prefect 3.x. 1) Root cause - Yes, Prefect’s tag-based concurrency uses server-side leases under the hood. A short lease TTL (roughly ~5 minutes) is maintained and automatically renewed in the background while a slot is held. - Your error indicates that lease renewal did not occur in time, so the server reclaimed the slot and Prefect cancelled the run to avoid over-allocation. The most common reason: the event loop was blocked by synchronous/CPU-bound work (e.g., time.sleep, requests, heavy CPU), so the background renewal coroutine could not run for several minutes. - In short: it’s not the mere existence of a TTL; it’s that renewal couldn’t happen (likely event-loop starvation or intermittent API connectivity). Useful refs: - Tag-based concurrency limits - Concurrency lease renewal API - prefect.concurrency.asyncio 2) Tag-based concurrency lease duration - There is no public, per-task parameter to set the lease TTL when using tags on tasks/flows. Prefect manages TTL and renewals for you. - Practically, the fix is not to increase TTL but to ensure renewals can run (i.e., avoid blocking the event loop). 3) Proper solution for your 100× ~5-minute ops (concurrency=1) - Keep tag-based concurrency if you want a global queue across runs/deployments. Then make sure the task body doesn’t block the event loop: - If the work is synchronous or CPU-bound, make the task synchronous so Prefect runs it in a threadpool; or explicitly offload it to a thread from an async task. - If the work is async, ensure it actually awaits I/O and does not use blocking libraries. Examples: - Make the task synchronous (simple and robust):
Copy code
from 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:
Copy code
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 workloads
- If you need a global queue across many runs/deployments, tag-based concurrency is the right tool. Submit all 100 items; 1 will run, the rest will remain in AwaitingConcurrencySlot until the slot is free. - The key is to ensure lease renewal can occur: - Avoid blocking the event loop in async tasks; offload blocking work to threads or use a synchronous task. - If you don’t need global queuing (only sequential within this one flow), you can simply iterate sequentially inside the flow and not use concurrency at all. - Batching (e.g., 10 at a time) is useful for failure isolation or partial retries, but it’s not required for lease renewal; it doesn’t address the underlying event-loop blockage cause. Concrete recommendation for your case - Keep your tag-based concurrency limit at 1. - Change the task to be synchronous or offload blocking code to a thread as shown above. - Keep your 10-hour batch-flow timeout; it’s fine and independent. If you can share what
perform_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 acquisition
o
@Marvin Our System Architecture We have Prefect workflows that orchestrate browser automation tasks. At the service layer, we use: - State machines that coordinate multi-step processes - Selenium/Appium for browser automation (inherently blocking - ~33 calls per workflow) - Django ORM for persistence (synchronous) - Only 2-3 true async I/O operations (HTTP requests, email polling) Our Prefect structure follows this pattern: # Parent Flow: Orchestrates batch processing @flow(name="Process Batch") async def process_batch_flow(items: list[dict]) -> list[Result]: # Submit 100 subflows concurrently subflows = [] for item in items: subflow = process_single_item_flow(item=item) subflows.append(subflow) # Wait for all subflows to complete results = await asyncio.gather(*subflows, return_exceptions=True) return results # Child Flow: Processes single item @flow(name="Process Single Item") async def process_single_item_flow(item: dict) -> Result: # Calls the actual work task result = await execute_work_task(item) return result # Task: Does the actual work (blocking operations) @task( name="Execute Work", tags=["work_concurrency"], # Tag-based concurrency limit = 1 ) async def execute_work_task(item: dict) -> Result: # Calls async state machine wrapper result = await _execute_state_machine(item) return result async def _execute_state_machine(item: dict) -> Result: # 33+ blocking Selenium calls inside here browser.navigate() # Blocking browser.fill_form() # Blocking browser.click() # Blocking # ... etc The Problem We're experiencing Prefect concurrency lease renewal failures: Concurrency lease renewal failed - slots are no longer reserved. asyncio.exceptions.CancelledError: Cancelled via cancel scope After investigation, we identified the root cause: Event loop blocking from Selenium operations prevents Prefect's lease renewal coroutine from running. Our Conundrum 1. 95% of our code is blocking (Selenium/Appium/Django ORM), so async provides minimal benefit 2. BUT: At the Prefect orchestration layer, asyncio.gather() allows us to submit 100 subflows concurrently without blocking 3. If we make everything sync, we're concerned that this pattern would block: @flow(name="Process Batch") def process_batch_flow(items: list[dict]) -> list[Result]: results = [] for item in items: result = process_single_item_flow(item=item) # ← Does this block? results.append(result) return results Our Questions 1. Does calling a sync subflow block the parent flow? If we convert everything to sync, will the loop above wait for each subflow to complete before submitting the next, thereby negating Prefect's tag-based concurrency control? 2. What's the minimal async approach? Should we: - Option A: Keep async flows for non-blocking submission, but make tasks synchronous? - Option B: Convert everything to sync and use .submit() on tasks/flows? - Option C: Use task mapping instead of subflows? 3. How does Prefect handle sync task submission? When we have: @flow() def batch_flow(items): futures = [] for item in items: future = work_task.submit(item=item) # ← Non-blocking? futures.append(future) results = [f.result() for f in futures] 3. Does .submit() on a sync task return immediately (like async), allowing concurrent execution via Prefect's scheduler? 4. What's the recommended pattern for our use case? Given that we have: - Blocking operations (Selenium/Django) - Low concurrency (1-5 simultaneous executions) - Need to orchestrate 100+ sequential tasks per batch - Tag-based concurrency limits already configured Should we refactor to full sync or keep async at the orchestration layer only? --- Context: We prefer a clean architecture over hybrid approaches. If async is only providing orchestration benefits but complicating the service layer, we'd rather refactor fully to sync - but only if it doesn't break concurrent submission behavior.
m
Your message was too long, here's your message at the allowed limit: Our System Architecture We have Prefect workflows that orchestrate browser automation tasks At the service layer we use - State machines that coordinate multi-step processes - Selenium/Appium for browser automation (inherently blocking - ~33 calls per workflow) - Django ORM for persistence (synchronous) - Only 2-3 true async I/O operations (HTTP requests email polling) Our Prefect structure follows this pattern # Parent Flow Orchestrates batch processing @flow(name= Process Batch ) async def process_batch_flow(items list[dict]) -> list[Result] # Submit 100 subflows concurrently subflows = [] for item in items subflow = process_single_item_flow(item=item) subflows append(subflow) # Wait for all subflows to complete results = await asyncio gather(*subflows return_exceptions=True) return results # Child Flow Processes single item @flow(name= Process Single Item ) async def process_single_item_flow(item dict) -> Result # Calls the actual work task result = await execute_work_task(item) return result # Task Does the actual work (blocking operations) @task( name= Execute Work tags=[ work_concurrency ] # Tag-based concurrency limit = 1 ) async def execute_work_task(item dict) -> Result # Calls async state machine wrapper result = await _execute_state_machine(item) return result async def _execute_state_machine(item dict) -> Result # 33+ blocking Selenium calls inside here browser navigate() # Blocking browser fill_form() # Blocking browser click() # Blocking # etc The Problem We're experiencing Prefect concurrency lease renewal failures Concurrency lease renewal failed - slots are no longer reserved asyncio exceptions CancelledError Cancelled via cancel scope After investigation we identified the root cause Event loop blocking from Selenium operations prevents Prefect's lease renewal coroutine from running Our Conundrum 1 95% of our code is blocking (Selenium/Appium/Django ORM) so async provides minimal benefit 2 BUT At the Prefect orchestration layer asyncio gather() allows us to submit 100 subflows concurrently without blocking 3 If we make everything sync we're concerned that this pattern would block @flow(name= Process Batch ) def process_batch_flow(items list[dict]) -> list[Result] results = [] for item in items result = process_single_item_flow(item=item) # ← Does this block? results append(result) return results Our Questions 1 Does calling a sync subflow block the parent flow? If we convert everything to sync will the loop above wait for each subflow to complete before submitting the next thereby negating Prefect's tag-based concurrency control? 2 What's the minimal async approach? Should we - Option A Keep async flows for non-blocking submission but make tasks synchronous? - Option B Convert everything to sync and use submit() on tasks/flows? - Option C Use task mapping instead of subflows? 3 How does Prefect handle sync task submission? When we have @flow() def batch_flow(items) futures = [] for item in items future = work_task submit(item=item) # ← Non-blocking? futures append(future) results = [f result() for f in futures] 3 Does submit() on a sync task return immediately (like async) allowing concurrent execution via Prefect's scheduler? 4 What's the recommended pattern for our use case? Given that we have - Blocking operations (Selenium/Django) - Low concurrency (1-5 simultaneous executions) - Need to orchestrate 100+ sequential tasks per batch - Tag-based concurrency limits already configured Should we
o
@Marvin Our System Architecture We have Prefect workflows orchestrating browser automation tasks. At the service layer: - State machines coordinating multi-step processes - Selenium/Appium for browser automation (blocking - ~33 calls per workflow) - Django ORM (synchronous) - Only 2-3 true async I/O operations (HTTP requests, email polling) Current structure: @flow(name="Process Batch") async def process_batch_flow(items: list[dict]) -> list[Result]: subflows = [process_single_item_flow(item=item) for item in items] results = await asyncio.gather(*subflows, return_exceptions=True) return results @flow(name="Process Single Item") async def process_single_item_flow(item: dict) -> Result: result = await execute_work_task(item) return result @task(tags=["work_concurrency"]) # Tag-based concurrency limit = 1 async def execute_work_task(item: dict) -> Result: result = await _execute_state_machine(item) return result async def _execute_state_machine(item: dict) -> Result: browser.navigate() # Blocking browser.fill_form() # Blocking # ... 33+ blocking calls The Problem Experiencing Prefect concurrency lease renewal failures: Concurrency lease renewal failed - slots are no longer reserved. asyncio.exceptions.CancelledError: Cancelled via cancel scope Root cause: Event loop blocking from Selenium prevents Prefect's lease renewal coroutine from running. Our Conundrum 1. 95% of our code is blocking, so async provides minimal benefit 2. BUT: asyncio.gather() lets us submit 100 subflows concurrently without blocking 3. If we make everything sync, will this pattern block? @flow() def process_batch_flow(items): results = [] for item in items: result = process_single_item_flow(item) # Does this block? results.append(result) return results Our Questions 1. Does calling a sync subflow block the parent flow? Will the loop wait for each subflow to complete before submitting the next, negating tag-based concurrency control? 2. What's the minimal async approach? Should we keep async flows for non-blocking submission but make tasks synchronous, or convert everything to sync and use .submit()? 3. How does Prefect handle sync task submission? @flow() def batch_flow(items): futures = [work_task.submit(item=i) for i in items] # Non-blocking? results = [f.result() for f in futures] 3. Does .submit() on sync tasks return immediately, allowing concurrent execution via Prefect's scheduler? 4. Recommended pattern for our use case? Given blocking operations, low concurrency (1-5), 100+ tasks per batch, and tag-based concurrency limits already configured, should we
m
thought for 338.4 seconds
Thanks for the detailed context — that makes this much easier to reason about. Short answers first, then a recommended pattern and examples. Answers to your questions 1) Do sync subflow calls block the parent flow? - Yes. In Prefect 3.x, calling a subflow directly is synchronous and blocks the parent until the subflow finishes. If you switch to a sync parent flow and call
process_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)
Copy code
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
Copy code
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):
Copy code
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)
- Subflow calls are blocking. To run flows concurrently, create separate flow runs via deployments and trigger them programmatically (each run will be picked up by a worker and run independently). This is heavier weight, but it’s the pattern if you want true flow-level concurrency. - See Run deployments for examples of triggering many flow runs and waiting on them. Why the lease renewal failures happened - When blocking code runs in an async function, it can starve the event loop. Prefect’s background coroutines (including concurrency lease renewal) then can’t run, causing “Concurrency lease renewal failed - slots are no longer reserved.” Making the blocking work run in threads (sync tasks or async + to_thread) prevents this. A few operational notes - Pick
max_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?
o
@Marvin I have a different question but dont feel like opening a new thread so i'll just ask here. I'm refactoring a large Prefect workflow system and need guidance on the proper architectural pattern for separating business logic from Prefect orchestration. Current Architecture: We have a service layer with pure Python business logic, and Prefect tasks that are thin wrappers around these services: # Service Layer (Pure Python - No Prefect) class RegistrationService: def execute_registration(self, user_data, config): context = self._create_context(user_data) clients = self._create_clients(config) try: result = self._run_workflow(context, clients) # 200+ lines self._create_audit_record(result) # DB write return result finally: self._cleanup_clients(clients) class PersistenceService: def update_user_status(self, user_id, success): user = User.objects.get(id=user_id) user.completed = success user.save() # Prefect Task Layer (Thin wrappers) @task(retries=0) def execute_registration_task(user_data, config): service = RegistrationService() return service.execute_registration(user_data, config) @task(retries=1) # <-- DB write, so retry=1 def update_user_status_task(user_id, success): service = PersistenceService() service.update_user_status(user_id, success) # Flow @flow def process_registration_flow(user_data): success = execute_registration_task(user_data, config) update_user_status_task(user_data.id, success) # Separate task? return success The Problem: We're wrapping almost every service method in a @task decorator. This pattern is repeated throughout the codebase, but I'm unsure when it's necessary vs unnecessary overhead. Key Questions: 1. When should service methods be wrapped in @task vs called directly? - Database writes like update_user_status() - should these be tasks for retry logic? - Or should they just be called directly from the parent task? 2. Persistence operations specifically: - Notice RegistrationService calls _create_audit_record() directly (DB write inside service) - But update_user_status() is wrapped as a separate task - This is inconsistent - should ALL persistence be tasks, or ALL be in service methods? 3. What are we trading off? - Task wrappers give: retries, observability in UI, granular error attribution - But add: indirection, more files to navigate, unclear transaction boundaries 4. Performance/overhead: - Does wrapping every operation in a task add significant overhead? - How granular should tasks be? Example scenarios: Simple DB update - task or direct call? # Option A: Task wrapper (current) update_status_task(id, status) # Option B: Direct in parent task @task def parent_task(): result = do_work() User.objects.filter(id=id).update(status=status) External API call - task or direct call? # Option A: Task wrapper @task(retries=3) def call_api_task(data): return requests.post(url, json=data) # Option B: In service class Service: def process(self): response = requests.post(url, json=data) What's the Prefect-recommended pattern for service layer separation, and when should operations be wrapped in @task vs called directly? I want observability and retries, but want to avoid over-engineering.
m
thought for 53.0 seconds
Great question — this is a common architectural fork when refactoring larger Prefect systems. High-level guidance - Keep your business logic pure and unaware of Prefect. - Use Prefect tasks to wrap orchestration boundaries that benefit from retries, visibility, concurrency limits, or isolation of failures. - Do not wrap every service method. Prefer coarse-grained tasks that encapsulate meaningful units of work, not tiny helpers. When to wrap in @task vs call directly Wrap in @task when: - The operation is a side effect or external boundary: - DB writes, external API calls, file/queue operations, Selenium/Appium, email, etc. - You want different retry/backoff/timeouts than surrounding work. - You want clear observability in the UI for this specific step (success/failure, logs). - You need concurrency shaping via tags/limits, or caching/persisted results for that step. - It’s long-running enough to justify a state in the UI (rule of thumb: 100ms+; commonly seconds/minutes). Call directly (inside another task/service method) when: - It’s pure computation, quick transformations, or glue logic. - It must be tightly atomic with other operations (e.g., within a DB transaction). - Retrying this step in isolation would be unsafe or non-idempotent. - It’s not worth the orchestration noise and overhead. Persistence operations (consistency vs observability) - Consistent unit of work: - If a DB write is part of one transactional unit with other steps and should not be retried independently, keep it in the same task as the work that produces it. Use DB transactions inside the task. - Independent retry/visibility: - If you want its own retry policy, backoff, and visibility, make it a separate task — but ensure idempotency (e.g., upsert, idempotency keys, dedupe checks). This is especially useful for “eventual” updates like audit trails. Your current inconsistency (audit inside service, status as a task) is okay if it reflects those semantics: audit tightly coupled to the registration flow, status update decoupled with separate retries. If you want consistent observability and policies across all persistence, move both to tasks — but only if you enforce idempotency. External API calls - Good candidates for a task with retries/backoff/timeouts. Alternatively, keep them inside a larger task if you want one atomic retry policy for all steps — but then you must make the entire unit idempotent. Trade-offs - Pros of task wrappers: independent retries/backoff, clear failure attribution and logs, concurrency and SLAs per step. - Cons: more files to navigate, more states in UI, overhead and transaction boundary complexity. You have to think about idempotency if tasks retry independently. Overhead and granularity - Each task run adds orchestration overhead (API calls, scheduling); usually small, but thousands of micro-tasks will slow you down and clutter the UI. - Rule of thumb: make tasks that do “real” work (>= hundreds of ms, often seconds+), encapsulate a side effect or important boundary, or need their own retry/visibility/concurrency policy. Batch tiny DB writes where possible. Two practical patterns Pattern A: Coarse-grained “unit-of-work” task (simplest, fewer moving parts) - Put the main workflow and persistence inside a single task; set retries at the task level. - Ensure idempotency inside the service if retries may partially succeed. ``` from prefect import flow, task, get_run_logger @task(retries=3, retry_delay_seconds=10) def execute_registration_task(user_data, config): logger = get_run_logger() service = RegistrationService() result = service.execute_registration(user_data, config) logger.info("Registration completed") return result @task(retries=5, retry_delay_seconds=5) def update_user_status_task(user_id, success): PersistenceService().update_user_status(user_id, success) @flow def process_registration_flow(user_data, config): success = False try:
result = execute_registration_task(user_data, config) success = True return result finally: # Runs regardless, with its own retry policy update_user_status_task.submit(user_data.id, success)
Copy code
Use 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.