<@ULVA73B9P> Hi Marvin, I’m testing Human in the Loop with a Prefect flow running on an AWS ECS work...
m
@Marvin Hi Marvin, I’m testing Human in the Loop with a Prefect flow running on an AWS ECS worker and I’m seeing an issue when the flow is paused waiting for user input. This is the simplified flow logic:
Copy code
from datetime import date

from prefect import flow, pause_flow_run

from core.logger import get_logger


@flow(flow_run_name="breakdown-production-{customer}-{breakdown_type}")
def breakdown_production(
        customer: str,
        breakdown_type: str,
        reference_date: date | None = None,
        dispatch: bool = False,
):
    logger = get_logger()

    <http://logger.info|logger.info>("Executing data quality")

    try:
        # simulate data quality failure
        raise Exception("Data quality failed")

    except:
        logger.exception("Data quality check failed.")

        action = pause_flow_run(wait_for_input=str)

        if action == "go":
            <http://logger.info|logger.info>("User chose to continue")
        else:
            raise ValueError("User chose not to continue")
The flow runs correctly until the pause point. Before that, all tasks complete successfully (SQL execution, exports, S3 uploads, etc.). When the flow reaches:
Copy code
pause_flow_run(wait_for_input=str)
Prefect logs:
Copy code
Pausing flow, execution will continue when this flow run is resumed.
However, after that I get this error:
Copy code
Concurrency lease renewal failed - slots are no longer reserved.
Terminating execution to prevent over-allocation.

Lease not found - it may have expired or been revoked

HTTP 410 Gone
Full error:
Copy code
prefect.exceptions.PrefectHTTPStatusError:
Client error '410 Gone' for url:

.../concurrency_limits/leases/<lease_id>/renew

Response:
{
  'detail': 'Lease not found - it may have expired or been revoked'
}
Then the worker crashes:
Copy code
Crash detected! Execution was cancelled by the runtime environment.

Finished in state Crashed()
The stack trace points back to:
Copy code
prefect/flow_runs.py

pause_flow_run()

time.sleep(poll_interval)

prefect._internal.concurrency.cancellation.CancelledError
My understanding is that while the flow is paused, the ECS worker process stays alive and keeps trying to renew the concurrency lease, but the lease expires/revoked before the user resumes the flow. Questions: 1. Is
pause_flow_run(wait_for_input=...)
expected to work with ECS workers, or does it require a long-running worker process? 2. Is there a recommended configuration for Human in the Loop on ECS? 3. Should the concurrency lease timeout be increased, or should paused flows release the ECS worker/concurrency slot? 4. Is there a different pattern recommended for production HITL flows (for example using deployments/work pools instead of keeping the task alive)? Thanks!
m
thought for 194.5 seconds
What you’re seeing is not the intended steady-state behavior for a paused HITL flow on ECS. Short version: 1. Yes,
pause_flow_run(wait_for_input=...)
is meant to work on ECS-backed flow runs. 2. But a paused flow keeps the run process alive, so the ECS task stays up while waiting. 3. The
410 Gone
means the run lost its concurrency lease while paused, and Prefect cancels execution to avoid oversubscribing concurrency. 4. For production HITL on ECS, the safest pattern is usually to avoid holding expensive infrastructure open for long human waits. A few details and recommendations below. --- What is happening When your flow hits:
Copy code
pause_flow_run(wait_for_input=str)
Prefect: - transitions the flow run into a Paused state - keeps the Python process alive - polls waiting for resume/input - keeps trying to renew the deployment concurrency lease in the background Your error:
Copy code
Concurrency lease renewal failed - slots are no longer reserved.
...
Lease not found - it may have expired or been revoked
HTTP 410 Gone
means that background lease renewal failed because the lease no longer existed. Once that happens, Prefect cancels the run on purpose: - to prevent the run from continuing without a reserved concurrency slot - which shows up as the worker/ECS task crashing or being cancelled So your diagnosis is basically right: the paused ECS task is still alive, and the lease it depends on was lost while waiting. --- Answers to your questions 1. Is
pause_flow_run(wait_for_input=...)
expected to work with ECS workers, or does it require a long-running worker process?
It is expected to work with ECS-launched flow runs. But there’s an important nuance: - it does not require the worker service itself to be the thing that stays alive - it does require the flow-run process (the ECS task/container running your code) to stay alive while paused So on ECS, a paused flow means: - the ECS task remains running - memory/CPU remain allocated - the run keeps its concurrency reservation unless/until that lease is lost This makes HITL technically possible on ECS, but often not ideal for long waits. --- 2. Is there a recommended configuration for Human in the Loop on ECS? Yes: use ECS HITL only for short pauses. Good fit: - brief approvals - operator input expected within minutes - cases where keeping the container alive is acceptable Not a great fit: - approvals that may sit for hours - overnight/manual review workflows - flows with tight deployment concurrency limits - cost-sensitive ECS environments If you stay with
pause_flow_run(...)
on ECS, I’d recommend: - keeping the pause duration short - making sure you’re on a recent Prefect 3 release - reviewing any deployment-level concurrency settings Also, if your deployment has a concurrency limit, paused runs will generally continue to occupy that slot while paused. --- 3. Should the concurrency lease timeout be increased, or should paused flows release the ECS worker/concurrency slot? Today, paused flows do not behave like “checkpoint and release infra.” They keep the run alive. So in current behavior: - ECS worker/infrastructure is not released automatically - deployment concurrency is not automatically freed just because the flow is paused That means you have two practical options: Option A: Increase lease/grace settings - This can help if your HITL pause is only moderately longer than the default lease behavior. - It reduces the chance of renewal expiry during pause. - But it does not solve the underlying cost/concurrency problem, because the ECS task still sits there waiting. Option B: Change the workflow pattern - Better for production if humans may take a while. - Avoids tying up ECS compute and deployment concurrency during approval waits. In most production cases, Option B is better. --- 4. Is there a different pattern recommended for production HITL flows?
Yes. For long human waits, I’d recommend a pattern that doesn’t keep the original flow run alive on ECS. Common better patterns: Pattern A: Split into multiple deployments / multiple flow runs - Flow 1 does the data quality work - If approval is needed, Flow 1 records state somewhere and exits - A human approves externally - Flow 2 is triggered with the approval decision and continues processing Benefits: - no idle ECS task - no long-held concurrency lease - easier to scale - more resilient to long approval delays Pattern B: Event- or automation-driven resume/continue workflow - First flow raises an event / writes a record / sends notification - Human approves via UI, form, or external system - Approval triggers another deployment or flow run Benefits: - decouples orchestration from waiting - better for hours/days-long pauses Pattern C: Use
pause_flow_run(...)
only for short interactive gates
- best if approval is expected quickly - keep timeout bounded - acceptable for “someone is actively watching this run right now” --- Why this is especially painful on ECS On ECS, a paused flow means: - container is still running - ECS resources are still billed - the run may hold deployment concurrency - long pauses create more surface area for lease renewal failure So even if you increase lease duration, that only makes the waiting container survive longer — it doesn’t make the pattern more efficient. --- What I would recommend for your case Given your example, I’d suggest: If the human responds quickly - keep
pause_flow_run(wait_for_input=...)
- make the pause explicitly short - ensure you’re on a recent Prefect 3 version Example:
Copy code
from datetime import date

from prefect import flow, pause_flow_run

from core.logger import get_logger


@flow(flow_run_name="breakdown-production-{customer}-{breakdown_type}")
def breakdown_production(
    customer: str,
    breakdown_type: str,
    reference_date: date | None = None,
    dispatch: bool = False,
):
    logger = get_logger()

    <http://logger.info|logger.info>("Executing data quality")

    try:
        raise Exception("Data quality failed")
    except Exception:
        logger.exception("Data quality check failed.")

        action = pause_flow_run(
            wait_for_input=str,
            timeout=300,
        )

        if action == "go":
            <http://logger.info|logger.info>("User chose to continue")
        else:
            raise ValueError("User chose not to continue")
If approvals may take a while - do not pause inside the ECS task - split the workflow into “request approval” and “continue after approval” --- One more thing to check The
410 Gone
can also happen if the deployment concurrency lease is revoked/expired for reasons beyond just “human waited too long,” including concurrency configuration changes or lease handling edge cases. So I’d verify: - whether the deployment has a concurrency limit configured - whether this only happens on long pauses - your exact Prefect version If you want, I can help you narrow this down further if you share: - your Prefect version - whether the deployment has a concurrency limit - how long the run stays paused before the
410 Gone
- whether this is Prefect 2.x or 3.x on the runtime actually executing this flow --- Relevant links - HITL / pause APIs in Prefect docs: https://docs.prefect.io/ - Prefect deployment/work pool concepts: https://docs.prefect.io/ - Prefect AWS integration: https://prefecthq.github.io/prefect-aws/ If you want, I can also help you design the split-deployment production HITL pattern for ECS and sketch the flow/deployment structure.
m
@Marvin to you the data requested: If you want, I can help you narrow this down further if you share: - your Prefect version --> 3.7.6 - whether the deployment has a concurrency limit --> yes setted to 7 - how long the run stays paused before the
410 Gone:
--> just 4 minutes - whether this is Prefect 2.x or 3.x on the runtime actually executing this flow: 3.x --> please try to modifiy the flow to see how we can solve the problem
m
thought for 26.9 seconds
Thanks — that extra detail is very helpful. Given: - Prefect 3.7.6 - deployment concurrency limit = 7 - failure happens after only ~4 minutes - runtime is definitely 3.x - infra is AWS ECS worker this does not look like “just the default 1-hour pause timeout.” A 4-minute failure is suspicious because it is close to the lease-renewal window, not the pause timeout. So I would not try to “fix” this only by tweaking the flow code around
pause_flow_run(...)
. The flow can be made safer, but the core issue is that a paused ECS-backed run is still holding infrastructure and a deployment concurrency lease. That said, if your goal is to modify the flow so it works reliably in production, the best fix is to change the HITL pattern. --- Important API note In Prefect 3.7.6,
pause_flow_run
has this signature:
Copy code
pause_flow_run(
    wait_for_input=None,
    timeout=3600,
    poll_interval=10,
    key=None,
)
So you can set a shorter timeout, but that alone probably won’t solve a lease-renewal failure at ~4 minutes. --- My recommendation: do not keep the ECS run paused for human input Instead of this:
Copy code
action = pause_flow_run(wait_for_input=str)
use a two-stage workflow: 1. First flow does the heavy work and detects the data-quality failure 2. It records a “pending approval” decision somewhere durable 3. It ends cleanly 4. A second flow is triggered later with the human decision This avoids: - idle ECS containers - lease renewal problems - deployment concurrency slots being tied up during human wait time --- Recommended modified design ## Option 1 — Best production pattern on ECS: stop and relaunch Instead of pausing inside the same flow run, fail or complete the first flow after creating an approval request. Example:
Copy code
from datetime import date
from uuid import uuid4

from prefect import flow
from core.logger import get_logger


def create_approval_request(customer: str, breakdown_type: str, reference_date: date | None) -> str:
    approval_id = str(uuid4())

    # Store this in your DB / S3 / app backend / ticketing system
    # status = "pending"
    # include enough context for the approver and for the continuation step

    return approval_id


@flow(flow_run_name="breakdown-production-{customer}-{breakdown_type}")
def breakdown_production(
    customer: str,
    breakdown_type: str,
    reference_date: date | None = None,
    dispatch: bool = False,
):
    logger = get_logger()

    <http://logger.info|logger.info>("Executing data quality")

    try:
        # simulate data quality failure
        raise Exception("Data quality failed")

    except Exception:
        logger.exception("Data quality check failed.")

        approval_id = create_approval_request(
            customer=customer,
            breakdown_type=breakdown_type,
            reference_date=reference_date,
        )

        <http://logger.info|logger.info>(f"Approval required. approval_id={approval_id}")

        # End this run cleanly or fail intentionally depending on your business logic
        return {
            "status": "waiting_for_approval",
            "approval_id": approval_id,
        }
Then create a second flow:
Copy code
from datetime import date

from prefect import flow
from core.logger import get_logger


def load_approval_decision(approval_id: str) -> str:
    # Read from your DB/backend
    # return "go" or "stop"
    return "go"


@flow(flow_run_name="resume-breakdown-{customer}-{breakdown_type}")
def resume_breakdown_production(
    approval_id: str,
    customer: str,
    breakdown_type: str,
    reference_date: date | None = None,
    dispatch: bool = False,
):
    logger = get_logger()

    action = load_approval_decision(approval_id)

    if action == "go":
        <http://logger.info|logger.info>("User chose to continue")
        # continue the rest of the pipeline here
    else:
        raise ValueError("User chose not to continue")
This is the pattern I’d use for production ECS. ---
## Option 2 — Keep HITL, but bound it tightly If you must use Prefect HITL directly in the flow, then make the pause short and explicit. Modified version:
Copy code
from datetime import date

from prefect import flow, pause_flow_run
from core.logger import get_logger


@flow(flow_run_name="breakdown-production-{customer}-{breakdown_type}")
def breakdown_production(
    customer: str,
    breakdown_type: str,
    reference_date: date | None = None,
    dispatch: bool = False,
):
    logger = get_logger()

    <http://logger.info|logger.info>("Executing data quality")

    try:
        raise Exception("Data quality failed")

    except Exception:
        logger.exception("Data quality check failed.")

        action = pause_flow_run(
            wait_for_input=str,
            timeout=180,
            poll_interval=5,
        )

        if action == "go":
            <http://logger.info|logger.info>("User chose to continue")
        else:
            raise ValueError("User chose not to continue")
Changes: -
timeout=180
-
poll_interval=5
-
except Exception:
instead of bare
except:
Why this helps: - If the approval doesn’t happen quickly, the flow exits before the lease-renewal edge case around ~4 minutes - It reduces how long ECS is held open - It’s safer operationally But this is only appropriate if a user is expected to respond very quickly. --- ## Option 3 — Use
suspend_flow_run
instead of
pause_flow_run
if your use case allows it Prefect 3 also exposes suspend APIs in `prefect.flow_runs`: -
suspend_flow_run
-
asuspend_flow_run
This is worth testing for your case because suspend is generally the more appropriate concept when you want orchestration to stop waiting actively. I have not verified enough implementation detail here to promise it will solve your ECS lease problem outright, so I want to be careful: I can’t guarantee
suspend_flow_run
will fully avoid the same infrastructure/concurrency coupling in your exact setup without testing.
But it is the next Prefect-native thing I would evaluate. If you want, I can help you rewrite your example using
suspend_flow_run(...)
and outline how to test it safely. --- Best concrete change to your flow right now If you want the most reliable immediate fix, I would rewrite the flow so it does not wait for human input inside the ECS task. Here is a practical version that keeps your logic but stops before the human step: ``` from datetime import date from uuid import uuid4 from prefect import flow from core.logger import get_logger def create_manual_decision(customer: str, breakdown_type: str, reference_date: date | None) -> str: decision_id = str(uuid4()) # Persist this somewhere: # - database table # - S3 json file # - internal API # # Example payload: # { # "decision_id": decision_id, # "customer": customer, # "breakdown_type": breakdown_type, # "reference_date": str(reference_date) if reference_date else None, # "status": "pending" # } return decision_id @flow(flow_run_name="breakdown-production-{customer}-{breakdown_type}") def breakdown_production( customer: str, breakdown_type: str, reference_date: date | None = None, dispatch: bool = False, ): logger = get_logger() logger.info("Executing data quality") try: raise Exception("Data quality failed") except Exception: logger.exception("Data quality check failed.") decision_id = create_manual_decision( customer=customer, breakdown_type=breakdown_type, reference_date=reference_date, ) logger.info( f"Manual approval required. decision_id={decision_id}. " "This flow run will now exit and wait for an external continuation trigger." ) return { "status": "manual_approval_required", "decision_id": decision_id, "customer": customer,
"breakdown_type": breakdown_type, "reference_date": str(reference_date) if reference_date else None, }
Copy code
Then trigger a second deployment when the person approves.

---

*If you insist on keeping the pause in one flow*

Then I would at least use this safer version:
from datetime import date from prefect import flow, pause_flow_run from core.logger import get_logger @flow(flow_run_name="breakdown-production-{customer}-{breakdown_type}") def breakdown_production( customer: str, breakdown_type: str, reference_date: date | None = None, dispatch: bool = False, ): logger = get_logger() logger.info("Executing data quality") try: raise Exception("Data quality failed") except Exception: logger.exception("Data quality check failed.") try: action = pause_flow_run( wait_for_input=str, timeout=180, poll_interval=5, ) except Exception: logger.exception("Pause/resume failed") raise if action == "go": logger.info("User chose to continue") else: raise ValueError("User chose not to continue") ``` But again: this is a mitigation, not a robust production fix for ECS. --- Why 4 minutes matters The fact that your crash happens around 4 minutes strongly suggests the failure is tied to lease renewal cadence, not to your flow logic. So: - changing
wait_for_input=str
to some other type won’t help - adding try/except around
pause_flow_run()
won’t really fix it - shortening the timeout may avoid the symptom - redesigning the workflow avoids the class of problem --- My final recommendation For Prefect 3.7.6 + ECS worker + deployment concurrency limit, use: - short pause only if humans respond immediately - otherwise split the flow into request/approve/continue stages If you want, next I can give you one of these two things: 1. a complete Prefect 3.x example of the two-deployment approval pattern, or 2. a test version using
suspend_flow_run()
to compare against
pause_flow_run()
on ECS.
m
@Marvin i would like to try with suspend
m
thought for 127.1 seconds
Yes —
suspend_flow_run
is the right thing to try on ECS
. For your use case, it is usually better than
pause_flow_run
because: - the flow run is suspended - the ECS task can exit - Prefect can resume later on new infrastructure - you avoid keeping the container alive while waiting for a human That is exactly the behavior you want for production HITL on ECS. --- Important differences from
pause_flow_run
pause_flow_run(...)
- keeps the current process alive - keeps polling - tends to keep infra/concurrency tied up while waiting
suspend_flow_run(...)
- suspends the run for later resumption - current process exits - resume happens by re-entering orchestration later So this is the best next experiment. --- Important requirement Use
persist_result=True
on the flow. Without persisted results, resumed/suspended workflows are much more fragile because Prefect needs stored state/results to continue correctly. --- Suggested rewrite of your flow Here is the cleanest version of your example to test with suspend:
Copy code
from datetime import date

from prefect import flow
from prefect.flow_runs import suspend_flow_run

from core.logger import get_logger


@flow(
    flow_run_name="breakdown-production-{customer}-{breakdown_type}",
    persist_result=True,
)
def breakdown_production(
    customer: str,
    breakdown_type: str,
    reference_date: date | None = None,
    dispatch: bool = False,
):
    logger = get_logger()

    <http://logger.info|logger.info>("Executing data quality")

    try:
        # simulate data quality failure
        raise Exception("Data quality failed")

    except Exception:
        logger.exception("Data quality check failed.")

        action = suspend_flow_run(
            wait_for_input=str,
            timeout=3600,
            key="dq-approval",
        )

        if action == "go":
            <http://logger.info|logger.info>("User chose to continue")
        else:
            raise ValueError("User chose not to continue")
--- Why I changed these parts -
persist_result=True
- important for suspend/resume behavior -
except Exception:
- better than bare
except:
-
key="dq-approval"
- helpful to make the suspension step stable/idempotent -
suspend_flow_run(...)
- this is the key change you want to test --- What to expect operationally When the flow reaches:
Copy code
action = suspend_flow_run(
    wait_for_input=str,
    timeout=3600,
    key="dq-approval",
)
expected behavior is: 1. flow run enters a suspended/paused waiting state in Prefect 2. ECS task exits instead of sitting alive 3. deployment concurrency/infrastructure pressure should be much lower than with
pause_flow_run
4. when you resume with input, Prefect will schedule continuation This is why
suspend_flow_run
is much more appropriate than
pause_flow_run
for ECS HITL. --- Very important caveat You should test whether the code before the suspend point is re-orchestrated on resume in a way that matters for your flow. In practice, with suspend/resume patterns, you should assume: - resume may involve re-entering the flow logic - already-completed work should be safe/idempotent - persisted results help Prefect avoid recomputing unnecessarily, but you still want your workflow design to be robust Since your real flow does: - SQL execution - exports - S3 uploads - etc. you should not assume that blindly suspending in the middle is safe unless those steps are idempotent or checkpointed appropriately. --- Safer production structure around suspend A better structure is: 1. do your expensive/data-mutating work 2. save any outputs/decision context durably 3. call
suspend_flow_run(...)
4. after resume, only run the continuation logic For example: ``` from datetime import date from prefect import flow from prefect.flow_runs import suspend_flow_run from core.logger import get_logger @flow( flow_run_name="breakdown-production-{customer}-{breakdown_type}", persist_result=True, )
def breakdown_production( customer: str, breakdown_type: str, reference_date: date | None = None, dispatch: bool = False, ): logger = get_logger() logger.info("Executing data quality") # real work before approval dq_passed = False try: if not dq_passed: raise Exception("Data quality failed") except Exception: logger.exception("Data quality check failed.") logger.info("Suspending flow run and waiting for operator decision") action = suspend_flow_run( wait_for_input=str, timeout=3600, key="dq-approval", ) if action != "go": raise ValueError("User chose not to continue") logger.info("Continuing pipeline after approval") # continue downstream work here
Copy code
---

*How to test it*

I’d test in this order:

*Test 1 — minimal suspend*
- deploy the simplified flow
- trigger it
- verify that once it reaches `suspend_flow_run`, the ECS task stops cleanly
- verify the flow run remains resumable in UI

*Test 2 — manual resume with input*
- resume it with `go`
- verify continuation works

*Test 3 — rejection path*
- resume it with any value other than `go`
- verify it fails with your intended business error

*Test 4 — long wait*
- leave it suspended longer than the 4-minute window that was breaking `pause_flow_run`
- confirm there is no lease-renewal crash while waiting

That last test is the important proof.

---

*What I expect relative to your current issue*

With `pause_flow_run`, you saw:

- lease renewal attempts while waiting
- HTTP 410
- runtime cancellation/crash

With `suspend_flow_run`, I would expect that class of failure to go away *while the run is waiting*, because the ECS task should not be sitting there renewing the lease the same way.

---

*One warning about user input type*

You are using:
wait_for_input=str
Copy code
That is fine for a quick test.

For production, I’d recommend a structured input model instead, so the resume payload is explicit and less error-prone.

Example:
from pydantic import BaseModel class ApprovalInput(BaseModel): action: str comment: str | None = None
Copy code
and then:
action = suspend_flow_run( wait_for_input=ApprovalInput, timeout=3600, key="dq-approval", ) if action.action == "go": logger.info("User chose to continue") else: raise ValueError("User chose not to continue")
Copy code
That tends to be cleaner in real HITL flows.

---

*Recommended next test version*

If you want the smallest diff from your current code, use this:
from datetime import date from prefect import flow from prefect.flow_runs import suspend_flow_run from core.logger import get_logger @flow( flow_run_name="breakdown-production-{customer}-{breakdown_type}", persist_result=True, ) def breakdown_production( customer: str, breakdown_type: str, reference_date: date | None = None, dispatch: bool = False, ): logger = get_logger() logger.info("Executing data quality") try: raise Exception("Data quality failed") except Exception: logger.exception("Data quality check failed.") action = suspend_flow_run( wait_for_input=str, timeout=3600, key="dq-approval", ) if action == "go": logger.info("User chose to continue") else: raise ValueError("User chose not to continue") ``` --- If you want, I can next give you: - a version with a Pydantic approval model, or - a full test checklist for resuming a suspended run from the UI/API.
m
@Marvin with suspend, it works --> Worker 'ECSWorker 590e9ebd-4f67-4de6-b21e-b14cd1c87b63' submitting flow run '06a43d99-2f3f-75e9-8000-71cd44bec85f' Running on worker id: 3170b4cb-9c3c-4c06-ab2d-daf228b25fd2. See worker logs here: app.prefect.cloud/account/…/3170b4cb-9c3c-4c06-ab2d-daf228b25fd2 Retrieving ECS task definition 'arnawsecseu south 1637423367921task definition/danda dev prefect breakdown production family17'... Using ECS task definition 'arnawsecseu south 1637423367921task definition/danda dev prefect breakdown production family17'... Creating ECS task run... Flow run 'ludicrous-giraffe' submitted to infrastructure Beginning flow run 'breakdown-production-alphabank-asset_class' for flow 'breakdown-production' === START BREAKDOWN PRODUCTION === Customer: alphabank Breakdown Type: asset_class Reference Date: 2026-05-01 Dispatch: True Finished in state Completed() Eseguo data quality Data quality check failed. Traceback (most recent call last): File "/tmp/prefect-flow-run-ya_x4wmo/src/flows/breakdown.py", line 73, in breakdown_production raise Exception('Data quality fallita') Exception: Data quality fallita Suspending flow run, execution will be rescheduled when this flow run is resumed. Finished in state Suspended(type=PAUSED) Worker 'ECSWorker 590e9ebd-4f67-4de6-b21e-b14cd1c87b63' submitting flow run '06a43d99-2f3f-75e9-8000-71cd44bec85f' Running on worker id: 3170b4cb-9c3c-4c06-ab2d-daf228b25fd2. See worker logs here: app.prefect.cloud/account/…/3170b4cb-9c3c-4c06-ab2d-daf228b25fd2 Retrieving ECS task definition 'arnawsecseu south 1637423367921task definition/danda dev prefect breakdown production family17'... Using ECS task definition 'arnawsecseu south 1637423367921task definition/danda dev prefect breakdown production family17'... Creating ECS task run... Flow run 'breakdown-production-alphabank-asset_class' submitted to infrastructure Beginning flow run 'breakdown-production-alphabank-asset_class' for flow 'breakdown-production' === START BREAKDOWN PRODUCTION === Customer: alphabank Breakdown Type: asset_class Reference Date: 2026-05-01 Dispatch: True Finished in state Cached(type=COMPLETED) Eseguo data quality Data quality check failed. Traceback (most recent call last): File "/tmp/prefect-flow-run-1odcjpnb/src/flows/breakdown.py", line 73, in breakdown_production raise Exception('Data quality fallita') Exception: Data quality fallita Suspending flow run, execution will be rescheduled when this flow run is resumed. User chose to proceed after data quality failure. Testing cache returned 'ciaone'. Proceeding with manifest dispatch. Dispatch is True, saving manifest file. === END BREAKDOWN PRODUCTION === Finished in state Completed() --> the theme now is that a ihave to cache what i've done till the dataquality checks
m
thought for 24.6 seconds
Yes — that result makes sense, and it confirms
suspend_flow_run
is the better fit for your ECS setup. What you’re seeing now is the normal suspend/resume behavior: - first run executes until
suspend_flow_run(...)
- run goes to
Suspended(type=PAUSED)
- ECS task exits - when resumed, Prefect starts a new ECS task - the flow is re-entered - previously completed work can be reused if it was persisted/cached - anything not protected by caching/persistence may run again So your conclusion is correct: yes, you need to cache or otherwise persist everything you do before the data quality approval step --- What your logs show clearly You have this sequence: - initial run: - flow starts - upstream work runs - suspend happens - resumed run: - flow starts again - some prior step shows
Finished in state Cached(type=COMPLETED)
- then execution continues after approval That means: - Prefect is re-orchestrating the flow on resume - cached/persisted steps are being reused - this is exactly the pattern you should lean into --- Recommended design For a suspend-based HITL flow on ECS, structure the flow into three parts: 1. pre-approval tasks - expensive SQL - exports - S3 uploads - any deterministic prep work 2. approval gate -
suspend_flow_run(...)
3. post-approval tasks - dispatch / publish / irreversible side effects The key is: - everything before the suspend point should be in tasks - those tasks should use result persistence and/or caching - post-approval work should be separated so it only runs after approval --- What not to do Avoid putting important pre-approval work inline in the flow body like this:
Copy code
@flow(persist_result=True)
def my_flow():
    run_big_sql()
    upload_files()
    action = suspend_flow_run(wait_for_input=str)
    publish_results()
Why this is risky: - plain Python in the flow body is not as easy for Prefect to cache/reuse as tasks - on resume, the flow body is re-entered - you may accidentally re-run side effects Instead, move those operations into tasks. --- Recommended rewrite pattern Here’s a practical structure for your case. ``` from datetime import date from pydantic import BaseModel from prefect import flow, task from prefect.flow_runs import suspend_flow_run from prefect.cache_policies import INPUTS from core.logger import get_logger class ApprovalInput(BaseModel): action: str comment: str | None = None @task( persist_result=True, cache_policy=INPUTS, ) def run_data_preparation( customer: str, breakdown_type: str, reference_date: date | None, ) -> dict: logger = get_logger() logger.info("Running SQL / exports / S3 uploads before approval") # your real pre-approval work here # must be safe to reuse from persisted result return { "customer": customer, "breakdown_type": breakdown_type, "reference_date": str(reference_date) if reference_date else None, "manifest_path": "s3://bucket/path/manifest.json", "dq_passed": False, } @task( persist_result=True, cache_policy=INPUTS, ) def evaluate_data_quality(prepared_data: dict) -> bool: logger = get_logger() logger.info("Executing data quality") # replace with real logic return prepared_data["dq_passed"] @task def dispatch_manifest(prepared_data: dict) -> None: logger = get_logger() logger.info("Dispatch is True, saving manifest file.") # irreversible side effect here # e.g. save/publish/send downstream @flow( flow_run_name="breakdown-production-{customer}-{breakdown_type}", persist_result=True, ) def breakdown_production( customer: str, breakdown_type: str, reference_date: date | None = None, dispatch: bool = False, ): logger = get_logger() logger.info("=== START BREAKDOWN PRODUCTION ===") logger.info(f"Customer: {customer}") logger.info(f"Breakdown Type: {breakdown_type}")
logger.info(f"Reference Date: {reference_date}") logger.info(f"Dispatch: {dispatch}") prepared_data = run_data_preparation( customer=customer, breakdown_type=breakdown_type, reference_date=reference_date, ) dq_passed = evaluate_data_quality(prepared_data) if not dq_passed: logger.exception("Data quality check failed.") approval = suspend_flow_run( wait_for_input=ApprovalInput, timeout=3600, key="dq-approval", ) if approval.action != "go": raise ValueError("User chose not to continue") logger.info("User chose to proceed after data quality failure.") logger.info("Proceeding with manifest dispatch.") if dispatch: dispatch_manifest(prepared_data) logger.info("=== END BREAKDOWN PRODUCTION ===")
Copy code
---

*Why this structure helps*

`run_data_preparation(...)`
- encapsulates all expensive pre-approval work
- persisted/cached, so resume can reuse it

`evaluate_data_quality(...)`
- clearly separates decision logic

`suspend_flow_run(...)`
- stops infra while waiting for human input

`dispatch_manifest(...)`
- only runs after approval
- avoids re-triggering irreversible side effects before approval

---

*Important operational rule*

Anything before suspend should be one of these:

- *idempotent*, or
- *cached/persisted*, or
- *both*

Anything after suspend should be:
- safe to run exactly once after approval
- ideally isolated into its own task(s)

---

*About caching specifically*

You said:

> the theme now is that i have to cache what i've done till the dataquality checks

Yes, exactly.

But more precisely, you want a mix of:

- *task result persistence*
- *cache policies / cache keys*
- *idempotent external writes*

These are related, but not identical.

---

*Practical guidance for your pre-approval steps*

## If a step reads data / computes data
Use a task with persisted results and caching.

Example:
@task( persist_result=True, cache_policy=INPUTS, ) def compute_something(customer: str, reference_date: str) -> dict: ...
Copy code
## If a step writes to S3
Prefer one of these:
- deterministic output path, so rerun overwrites same object safely
- check-if-exists logic
- persist the produced path as the task result

Example:
@task(persist_result=True, cache_policy=INPUTS) def export_to_s3(customer: str, reference_date: str) -> str: path = f"s3://my-bucket/exports/{customer}/{reference_date}/file.csv" # write once or overwrite safely return path
Copy code
## If a step writes to a database
Make it idempotent:
- upsert instead of insert
- use a run/business key
- avoid “append duplicate row every retry/resume”

---

*One subtle but important point*

Caching helps when Prefect can recognize the task as already done for the same inputs.

But for *external side effects*, do not rely on caching alone.

For example:
- if a task uploaded a file but its state/result was not available for some reason
- Prefect may rerun the task
- so the operation itself should still be safe

That’s why *idempotency* matters in addition to caching.

---

*Suggested minimal adaptation of your current code*

If you don’t want a big rewrite yet, do this first:

1. move all pre-DQ work into one or more tasks
2. add `persist_result=True` to flow and tasks
3. suspend only after those tasks
4. move post-approval side effects into separate tasks

Minimal pattern:
from datetime import date from pydantic import BaseModel from prefect import flow, task from prefect.flow_runs import suspend_flow_run class ApprovalInput(BaseModel): action: str @task(persist_result=True) def prepare_breakdown(customer: str, breakdown_type: str, reference_date: date | None) -> dict: # sql, exports, s3 uploads return { "customer": customer, "breakdown_type": breakdown_type, } @task def finalize_breakdown(prepared: dict, dispatch: bool) -> None:
if dispatch: # final publish/send step pass @flow( flow_run_name="breakdown-production-{customer}-{breakdown_type}", persist_result=True, ) def breakdown_production( customer: str, breakdown_type: str, reference_date: date | None = None, dispatch: bool = False, ): prepared = prepare_breakdown(customer, breakdown_type, reference_date) # dq fails approval = suspend_flow_run( wait_for_input=ApprovalInput, timeout=3600, key="dq-approval", ) if approval.action != "go": raise ValueError("User chose not to continue") finalize_breakdown(prepared, dispatch) ``` --- Bottom line You’ve now validated the correct ECS pattern: -
pause_flow_run
was the wrong fit here -
suspend_flow_run
works - the remaining task is to checkpoint the pre-approval work into persisted/cached tasks That’s the right production direction. If you want, I can next help you with one of these: 1. refactor your real flow into pre-approval/post-approval tasks, or 2. show a good cache strategy for SQL + S3 export tasks so they don’t rerun on resume.