<@ULVA73B9P> I have two deployments on a process worker. One deployment is the master flow that is...
s
@Marvin I have two deployments on a process worker. One deployment is the master flow that is used to submit child flow runs to the second deployment. I want to create a temporary Dask cluster in the master flow definition and for the second to connect to that existing cluster. Also, for the flow definition in the second deployment I want the option to be able to launch a temporary Dask cluster for cases where I want to trigger a flow run directly, i.e. without using the Dask cluster from the master flow. I usually go with this task runner definition:
Copy code
@flow(
    task_runner=DaskTaskRunner(
        cluster_class=PBSCluster,  # type: ignore[arg-type]
        cluster_kwargs=pbs_cluster_kwargs,
        adapt_kwargs={"maximum": MAX_WORKERS}
    ),
    log_prints=True,
    persist_result=False,
    retries=1,
    retry_delay_seconds=20
)
def my_flow(
    import_file: str,
    working_dir: Path | None = None,
    overwrite: bool = False
):
   ...
m
thought for 136.8 seconds
Yes — but with an important caveat: *If the second flow is run as a separate deployment, it does not automatically inherit the first deployment’s
DaskTaskRunner
or its temporary cluster.* In Prefect 3.x, each deployed flow run gets its own task runner instance. So if both deployments are decorated like this:
Copy code
@flow(
    task_runner=DaskTaskRunner(
        cluster_class=PBSCluster,
        cluster_kwargs=pbs_cluster_kwargs,
        adapt_kwargs={"maximum": MAX_WORKERS},
    )
)
then each deployment run will create its own cluster. ### What does work If you want the child deployment to use an already-running Dask cluster, configure it with a scheduler address instead of asking it to create a cluster.
DaskTaskRunner
supports both patterns: -
cluster_class=...
→ create/manage a temporary cluster -
address="tcp://..."
→ connect to an existing cluster I verified
DaskTaskRunner
takes both
cluster
and
address
args, plus
cluster_class
,
cluster_kwargs
, and
adapt_kwargs
. ### Recommended pattern for your case Make the second flow configurable: - if a scheduler address is provided, connect to that cluster - otherwise, create its own temporary
PBSCluster
That gives you: - master deployment path: master creates cluster, child connects to it - direct run path: child creates its own cluster ## Important design note For this to work across separate deployments, the scheduler address created by the master flow must be passed to the child deployment somehow, for example via: - a parameter - an environment variable available to the child run - some external config/state store Also, the cluster must remain alive long enough for the child flow run to use it. If the master flow exits and tears down its runner/cluster before the child has finished, the child will lose the cluster. ## Suggested implementation Instead of hardcoding the task runner in the decorator, build it dynamically based on a parameter. Example:
Copy code
python
from pathlib import Path
from typing import Optional

from prefect import flow, task
from prefect_dask import DaskTaskRunner
from dask_jobqueue import PBSCluster


MAX_WORKERS = 10

pbs_cluster_kwargs = {
    # your PBS settings here
}


def make_dask_task_runner(scheduler_address: Optional[str] = None) -> DaskTaskRunner:
    if scheduler_address:
        return DaskTaskRunner(address=scheduler_address)

    return DaskTaskRunner(
        cluster_class=PBSCluster,
        cluster_kwargs=pbs_cluster_kwargs,
        adapt_kwargs={"maximum": MAX_WORKERS},
    )


@task
def do_work(x: int) -> int:
    return x * 2


@flow(log_prints=True, persist_result=False, retries=1, retry_delay_seconds=20)
def child_flow(
    import_file: str,
    working_dir: Path | None = None,
    overwrite: bool = False,
    scheduler_address: str | None = None,
):
    runner = make_dask_task_runner(scheduler_address)

    with runner:
        futures = [runner.submit(do_work, {"x": i}) for i in range(10)]
        results = [future.result() for future in futures]

    return results
## But there’s a more idiomatic Prefect option If possible, don’t make this a separate deployment. If your “child flow” is really part of the same orchestration and you want it to share the same Dask cluster, the simplest pattern is: - make it a subflow or regular function call - call it directly from the master flow, not via
run_deployment
That way, it can share execution context more naturally, and you avoid cross-deployment cluster handoff complexity. ### Better if sharing the same cluster is the goal ```python from prefect import flow, task from prefect_dask import DaskTaskRunner from dask_jobqueue import PBSCluster @task def do_work(x): return x * 2 @flow def child_flow(): return [do_work.submit(i) for i in range(10)] @flow( task_runner=DaskTaskRunner( cluster_class=PBSCluster, cluster_kwargs=pbs_cluster_kwargs, adapt_kwargs={"maximum": MAX_WORKERS}, )
) def master_flow(): child_flow()
Copy code
That is much easier if the only reason for the second deployment is reuse.

## If you *must* keep two deployments

Then the pattern is:

1. Master flow creates/owns a Dask cluster
2. Master determines the scheduler address
3. Master starts the child deployment and passes `scheduler_address`
4. Child uses `DaskTaskRunner(address=scheduler_address)`
5. If no address is passed, child falls back to `PBSCluster`

### Conceptually

```python
@flow
def child_flow(scheduler_address: str | None = None):
    runner = (
        DaskTaskRunner(address=scheduler_address)
        if scheduler_address
        else DaskTaskRunner(
            cluster_class=PBSCluster,
            cluster_kwargs=pbs_cluster_kwargs,
            adapt_kwargs={"maximum": MAX_WORKERS},
        )
    )
    ...
## One more practical caveat Using
@flow(task_runner=...)
is great when the runner is static. But for your use case, the runner needs to depend on runtime input (
scheduler_address
), so a static decorator is awkward. A runtime-selected runner is usually cleaner. ## Bottom line Yes, the second flow can connect to an existing Dask cluster — but only if you explicitly give it the scheduler address. No, two separate deployments will not automatically share the temporary cluster created by one deployment’s
DaskTaskRunner
.
If you want, I can sketch a full Prefect 3 example showing: - a master deployment that creates a
PBSCluster
- submits a child deployment - passes the Dask scheduler address - and a child flow that falls back to its own cluster when run directly Relevant CLI/docs: -
prefect deploy
CLI: verified available via
prefect deploy --help
- Prefect Dask integration package: https://docs.prefect.io/integrations/prefect-dask - Prefect deployments docs: https://docs.prefect.io/v3/deploy/index
s
@Marvin Great and how can I read the Dask cluster address from the master flow deployment?
m
thought for 98.8 seconds
Yes — from inside the master flow you can read the scheduler address from the active
DaskTaskRunner
. The cleanest options are: ## Option 1: Read it from the flow context
Copy code
python
from prefect import flow
from prefect.context import FlowRunContext
from prefect_dask import DaskTaskRunner
from dask_jobqueue import PBSCluster

@flow(
    task_runner=DaskTaskRunner(
        cluster_class=PBSCluster,
        cluster_kwargs=pbs_cluster_kwargs,
        adapt_kwargs={"maximum": MAX_WORKERS},
    )
)
def master_flow():
    task_runner = FlowRunContext.get().task_runner
    scheduler_address = task_runner.client.scheduler.address

    print(f"Dask scheduler address: {scheduler_address}")
    return scheduler_address
That
client
property is available on
DaskTaskRunner
, and
client.scheduler.address
gives you the address string. ## Option 2: Read it via
get_dask_client()
This also works, and is especially handy inside tasks:
Copy code
python
from prefect import flow, task
from prefect_dask import DaskTaskRunner, get_dask_client
from dask_jobqueue import PBSCluster

@task
def get_scheduler_address():
    with get_dask_client() as client:
        return client.scheduler.address

@flow(
    task_runner=DaskTaskRunner(
        cluster_class=PBSCluster,
        cluster_kwargs=pbs_cluster_kwargs,
        adapt_kwargs={"maximum": MAX_WORKERS},
    )
)
def master_flow():
    address = get_scheduler_address.submit().result()
    print(f"Dask scheduler address: {address}")
    return address
## Important caveat The cluster/client may be created lazily. That means the task runner may not actually start the cluster until something uses it. So if you try to read the address immediately at the top of the flow, you may need to force initialization first by submitting at least one task. Example:
Copy code
python
from prefect import flow, task
from prefect.context import FlowRunContext
from prefect_dask import DaskTaskRunner
from dask_jobqueue import PBSCluster

@task
def warmup():
    return "ok"

@flow(
    task_runner=DaskTaskRunner(
        cluster_class=PBSCluster,
        cluster_kwargs=pbs_cluster_kwargs,
        adapt_kwargs={"maximum": MAX_WORKERS},
    )
)
def master_flow():
    warmup.submit().result()

    task_runner = FlowRunContext.get().task_runner
    scheduler_address = task_runner.client.scheduler.address

    print(f"Dask scheduler address: {scheduler_address}")
    return scheduler_address
## If you want to pass it to the child deployment You’d typically: 1. warm up the cluster 2. read
scheduler_address
3. pass it as a parameter to the child deployment 4. have the child use
DaskTaskRunner(address=scheduler_address)
## One caution for your architecture If the cluster is owned by the master flow’s task runner, it will usually be cleaned up when that flow exits. So if the child deployment needs to keep using it, the master flow must stay alive until the child is done, or you need to manage the Dask cluster lifetime outside the task runner. Useful links: - Prefect Dask integration: prefect-dask docs - Deployments in Prefect 3: deployment docs If you want, I can show a full example of: -
master_flow
reading the address - triggering a second deployment - and
child_flow
using either the passed address or creating its own
PBSCluster
fallback.
s
@Marvin For the PBS cluster definition in this master flow, Could I use these specs:
Copy code
adapt_kwargs={"minimum": 1, "maximum": MAX_WORKERS}
m
thought for 11.1 seconds
Yes — that is a valid and common pattern for adaptive scaling with a `PBSCluster`:
Copy code
adapt_kwargs={"minimum": 1, "maximum": MAX_WORKERS}
That tells Dask to keep at least 1 worker alive and scale up to
MAX_WORKERS
as needed. ## What it means - `minimum=1`: - the cluster should keep one worker available - useful if you want to avoid scaling down to zero - can reduce startup latency for subsequent tasks - `maximum=MAX_WORKERS`: - caps autoscaling at that many workers ## Compared to your current version You currently have:
Copy code
python
adapt_kwargs={"maximum": MAX_WORKERS}
Adding
minimum=1
is perfectly reasonable if you want the cluster to remain “warm”. ## One practical caveat with your master/child setup If your goal is: - master creates the cluster - child deployment connects to it then
minimum=1
can help ensure the cluster still has at least one worker available while the child flow starts up. But it does not by itself guarantee cluster lifetime — the bigger issue is still that the cluster is owned by the master flow’s
DaskTaskRunner
. When that flow exits, Prefect will tear the runner down, and the cluster may go away regardless of adaptive settings. So: -
adapt_kwargs={"minimum": 1, "maximum": MAX_WORKERS}
yes, good for scaling behavior - but for cross-deployment reuse, you still need to manage flow/cluster lifetime carefully ## Example
Copy code
python
@flow(
    task_runner=DaskTaskRunner(
        cluster_class=PBSCluster,
        cluster_kwargs=pbs_cluster_kwargs,
        adapt_kwargs={"minimum": 1, "maximum": MAX_WORKERS},
    ),
    log_prints=True,
    persist_result=False,
    retries=1,
    retry_delay_seconds=20,
)
def master_flow():
    ...
If you want, I can also help you reason about whether
minimum=0
or
minimum=1
is better for a PBS-backed cluster specifically, since queueing/scheduler behavior can make that tradeoff a bit different from local Dask.
s
@Marvin
Copy code
Sure, please expand on this. 
Also, show me an example on how best to manage flow/cluster lifetime carefully
m
thought for 13.8 seconds
Yes — for a PBS-backed Dask cluster,
minimum=0
vs
minimum=1
is mostly a tradeoff between queue/startup overhead and resource residency. ##
minimum=0
vs
minimum=1
on
PBSCluster
###
minimum=0
Use this when you want the cluster to scale all the way down when idle. Pros - Frees resources when the cluster is idle - Better if worker jobs are expensive or tightly quota-limited - Good for sporadic workloads Cons - Next burst of work may wait for PBS jobs to queue/start - If your child deployment starts after a gap, it may see extra latency while workers come back ###
minimum=1
Use this when you want to keep the cluster “warm”. Pros - Reduces latency for follow-on work - Helpful when master launches child work soon after - Less chance that the cluster is technically alive but has zero workers when the child connects Cons - Keeps at least one PBS worker allocated - May consume scarce HPC allocation while idle - Still does not keep the cluster alive after the owning flow/task runner exits ## The most important lifecycle point Adaptive scaling only controls workers. It does not control the lifetime of: - the Dask scheduler - the
PBSCluster
object - the Prefect
DaskTaskRunner
If the master flow owns the cluster via
DaskTaskRunner(cluster_class=PBSCluster, ...)
, then when the master flow finishes, that runner is cleaned up and the cluster can be torn down. So for your cross-deployment case, the safest pattern is: Do not let the master flow’s task runner own the shared cluster. Instead: 1. Create/manage the Dask cluster explicitly in the master flow 2. Read its scheduler address 3. Launch the child deployment with that address 4. Keep the master flow alive until the child finishes 5. Then explicitly close the cluster/client That gives you deterministic lifetime management. --- # Recommended pattern: explicit cluster management Instead of:
Copy code
python
@flow(task_runner=DaskTaskRunner(cluster_class=PBSCluster, ...))
def master_flow():
    ...
prefer:
Copy code
python
@flow
def master_flow():
    with PBSCluster(...) as cluster:
        cluster.adapt(minimum=1, maximum=MAX_WORKERS)
        with Client(cluster) as client:
            scheduler_address = client.scheduler.address
            ...
Then your child deployment can use:
Copy code
python
DaskTaskRunner(address=scheduler_address)
This is usually the cleanest approach for “shared temporary cluster across multiple flow runs”. --- # Example architecture ## Child flow: can reuse existing cluster or create its own
Copy code
python
from pathlib import Path
from prefect import flow, task
from prefect_dask import DaskTaskRunner
from dask_jobqueue import PBSCluster

MAX_WORKERS = 20

pbs_cluster_kwargs = {
    # queue, project, cores, memory, walltime, etc.
}

@task
def do_work(x: int) -> int:
    return x * 2


@flow(log_prints=True, persist_result=False, retries=1, retry_delay_seconds=20)
def child_flow(
    import_file: str,
    working_dir: Path | None = None,
    overwrite: bool = False,
    scheduler_address: str | None = None,
):
    if scheduler_address:
        runner = DaskTaskRunner(address=scheduler_address)
    else:
        runner = DaskTaskRunner(
            cluster_class=PBSCluster,
            cluster_kwargs=pbs_cluster_kwargs,
            adapt_kwargs={"minimum": 1, "maximum": MAX_WORKERS},
        )

    with runner:
        futures = [runner.submit(do_work, {"x": i}) for i in range(10)]
        results = [future.result() for future in futures]

    return results
## Master flow: explicitly owns the shared cluster This example keeps the cluster alive while the child deployment runs. ```python from distributed import Client from dask_jobqueue import PBSCluster from prefect import flow from prefect.deployments import run_deployment MAX_WORKERS = 20 pbs_cluster_kwargs = { # queue, project, cores, memory, walltime, etc. } @flow(log_prints=True) async def master_flow(import_file: str):
cluster = PBSCluster(**pbs_cluster_kwargs) client = Client(cluster) try: cluster.adapt(minimum=1, maximum=MAX_WORKERS) scheduler_address = client.scheduler.address print(f"Shared Dask scheduler: {scheduler_address}") child_flow_run = await run_deployment( name="child-flow/child-deployment", parameters={ "import_file": import_file, "scheduler_address": scheduler_address, }, timeout=0, ) # Wait for the child flow to complete before cleaning up the cluster. # Depending on your pattern, you may poll flow run state here. print(f"Launched child flow run: {child_flow_run.id}") # Keep the master alive until child is finished. # You would add polling logic here. finally: client.close() cluster.close()
Copy code
---

# Best practice for “manage lifetime carefully”

## Best option
*Master explicitly creates the cluster and does not exit until all dependent child runs are complete.*

That is the safest temporary-cluster pattern.

## Less safe option
Have the master flow use `DaskTaskRunner(cluster_class=...)` and try to share its address with child deployments.

Why it’s less safe:
- cluster startup is tied to the task runner
- cleanup is tied to flow completion
- there is no strong guarantee the cluster survives independently of the master run lifecycle

---

# How to keep master alive until child finishes

If you use `run_deployment(...)`, the master should wait for the child flow run to reach a final state before closing the cluster.

Conceptually:

```python
child = await run_deployment(...)

while not child.state.is_final():
    # sleep, refresh state from API
    ...
The exact polling implementation depends on whether you want: - simple polling with the Prefect client - a built-in helper if you already have one in your codebase If you want, I can give you a fully worked Prefect 3 example that includes: - explicit PBS cluster creation - adaptive scaling -
run_deployment(...)
- polling the child run until completion - and then clean cluster teardown --- # Recommendation for your PBS case For HPC/PBS specifically, I’d usually suggest: -
minimum=1
if: - the child deployment starts soon after master - worker startup/queue delay is painful - keeping one worker allocated is acceptable -
minimum=0
if: - allocations are precious - idle workers are undesirable - extra startup delay is acceptable For your shared-cluster-across-deployments case, I’d lean toward:
Copy code
python
cluster.adapt(minimum=1, maximum=MAX_WORKERS)
because it reduces the chance of the child connecting to a cluster that has gone idle on the worker side. But again, the real protection is not
minimum=1
— it’s keeping the master flow alive and explicitly owning the cluster until the child is finished. Useful links: - Prefect Dask integration: prefect-dask docs - Prefect deployment docs: deployment docs If you want, I can next give you a complete master/child code sample with: -
master_flow.py
-
child_flow.py
- deployment creation - and polling logic for child completion.
s
@Marvin One of your suggestion for the child flow design was:
Copy code
python
from pathlib import Path
from prefect import flow, task
from prefect_dask import DaskTaskRunner
from dask_jobqueue import PBSCluster

MAX_WORKERS = 20

pbs_cluster_kwargs = {
    # queue, project, cores, memory, walltime, etc.
}

@task
def do_work(x: int) -> int:
    return x * 2


@flow(log_prints=True, persist_result=False, retries=1, retry_delay_seconds=20)
def child_flow(
    import_file: str,
    working_dir: Path | None = None,
    overwrite: bool = False,
    scheduler_address: str | None = None,
):
    if scheduler_address:
        runner = DaskTaskRunner(address=scheduler_address)
    else:
        runner = DaskTaskRunner(
            cluster_class=PBSCluster,
            cluster_kwargs=pbs_cluster_kwargs,
            adapt_kwargs={"minimum": 1, "maximum": MAX_WORKERS},
        )

    with runner:
        futures = [runner.submit(do_work, {"x": i}) for i in range(10)]
        results = [future.result() for future in futures]

    return results
If I use the runner's context manager and using the task runner's address from the master flow, would that be okay? Or would it tear down the infrastructure coming from the Master's task runner?
m
thought for 9.0 seconds
Using
with runner:
in the child flow with
Copy code
python
runner = DaskTaskRunner(address=scheduler_address)
is okay and should not tear down the master’s cluster infrastructure. ## Why When
DaskTaskRunner
is created with an existing
address
, it connects to an already-running Dask scheduler instead of creating a new cluster. So in the child:
Copy code
python
with DaskTaskRunner(address=scheduler_address) as runner:
    ...
the runner is managing: - the Dask client connection it opened - not the external cluster itself That means exiting the context should close the child’s client/session, but it should not shut down the scheduler or PBS workers created by the master. ## What would tear it down It would be torn down if the child created the cluster itself, e.g.:
Copy code
python
DaskTaskRunner(
    cluster_class=PBSCluster,
    cluster_kwargs=...,
    adapt_kwargs=...,
)
In that case, the runner owns the cluster lifecycle, and leaving
with runner:
would clean up that cluster. So the rule of thumb is: -
DaskTaskRunner(address=...)
→ connects only, does not own cluster -
DaskTaskRunner(cluster_class=...)
→ creates/owns cluster, cleanup on exit ## For your exact pattern Yes, this is the right split: ### Master - explicitly creates and owns the PBS cluster - gets scheduler address - keeps running until child completes - finally closes cluster ### Child - uses
DaskTaskRunner(address=scheduler_address)
- enters/exits its own runner context safely - does not destroy the shared cluster ## One caution Even though the child won’t tear down the cluster, the master still can. So this is safe only if: - the master flow stays alive - the master does not close its
Client
/
PBSCluster
- cleanup happens after the child is done ## Short answer Yes — using
with runner:
in the child with
DaskTaskRunner(address=...)
is fine, and it should not tear down infrastructure owned by the master.
If you want, I can turn this into a final recommended pair of implementations: - master flow that explicitly owns
PBSCluster
- child flow that either connects via
address
or falls back to creating its own cluster
s
@Marvin Show me your final recommeded pair of implementations.
m
thought for 24.7 seconds
Absolutely — here’s the final recommended pair for your setup. This version is designed for your requirements: - master deployment creates and owns a temporary
PBSCluster
- child deployment connects to that cluster when invoked by the master - child deployment can also run standalone and create its own temporary cluster - the master keeps the cluster alive until the child flow run finishes - the child using
DaskTaskRunner(address=...)
will not tear down the master’s cluster --- # Recommended design ## High-level pattern ###
master_flow
- explicitly creates
PBSCluster
- creates a
distributed.Client
- reads
scheduler_address
- launches child deployment with
scheduler_address
as a parameter - waits for child flow run completion - then closes client + cluster ###
child_flow
- if
scheduler_address
is provided: - use
DaskTaskRunner(address=scheduler_address)
- otherwise: - use
DaskTaskRunner(cluster_class=PBSCluster, ...)
- execute tasks inside
with runner:
--- #
child_flow.py
Copy code
python
from pathlib import Path

from prefect import flow, task
from prefect_dask import DaskTaskRunner
from dask_jobqueue import PBSCluster

MAX_WORKERS = 20

pbs_cluster_kwargs = {
    # Example fields; replace with your real settings
    # "queue": "myqueue",
    # "account": "myproject",
    # "cores": 4,
    # "memory": "16GB",
    # "walltime": "01:00:00",
    # "job_extra_directives": [...],
}


@task
def do_work(x: int) -> int:
    return x * 2


@flow(
    log_prints=True,
    persist_result=False,
    retries=1,
    retry_delay_seconds=20,
)
def child_flow(
    import_file: str,
    working_dir: Path | None = None,
    overwrite: bool = False,
    scheduler_address: str | None = None,
):
    if scheduler_address:
        print(f"Connecting to existing Dask scheduler at {scheduler_address}")
        runner = DaskTaskRunner(address=scheduler_address)
    else:
        print("No scheduler address provided; creating temporary PBS-backed Dask cluster")
        runner = DaskTaskRunner(
            cluster_class=PBSCluster,
            cluster_kwargs=pbs_cluster_kwargs,
            adapt_kwargs={"minimum": 1, "maximum": MAX_WORKERS},
        )

    with runner:
        futures = [runner.submit(do_work, {"x": i}) for i in range(10)]
        results = [future.result() for future in futures]

    print(f"Child flow results: {results}")
    return results
--- #
master_flow.py
This version explicitly manages cluster lifetime and waits for the child deployment to finish before tearing the cluster down. ```python import asyncio from pathlib import Path from uuid import UUID from distributed import Client from dask_jobqueue import PBSCluster from prefect import flow from prefect.deployments.flow_runs import run_deployment from prefect.client.orchestration import get_client from prefect.client.schemas.objects import StateType MAX_WORKERS = 20 pbs_cluster_kwargs = { # Example fields; replace with your real settings # "queue": "myqueue", # "account": "myproject", # "cores": 4, # "memory": "16GB", # "walltime": "010000", # "job_extra_directives": [...], } async def wait_for_flow_run(flow_run_id: UUID, poll_interval: int = 10) -> None: async with get_client() as client: while True: flow_run = await client.read_flow_run(flow_run_id) state = flow_run.state if state is None: print(f"Flow run {flow_run_id} has no state yet") else: print(f"Child flow run state: {state.type.value}") if state.type in { StateType.COMPLETED, StateType.FAILED, StateType.CANCELLED, StateType.CRASHED, }: if state.type != StateType.COMPLETED: raise RuntimeError(
f"Child flow run {flow_run_id} finished in state {state.type.value}" ) return await asyncio.sleep(poll_interval) @flow(log_prints=True) async def master_flow( import_file: str, working_dir: Path | None = None, overwrite: bool = False, ): cluster = PBSCluster(**pbs_cluster_kwargs) client = Client(cluster) try: cluster.adapt(minimum=1, maximum=MAX_WORKERS) scheduler_address = client.scheduler.address print(f"Created shared Dask scheduler at: {scheduler_address}") child_run = await run_deployment( name="child-flow/child-deployment", parameters={ "import_file": import_file, "working_dir": str(working_dir) if working_dir else None, "overwrite": overwrite, "scheduler_address": scheduler_address, }, timeout=0, poll_interval=5, ) print(f"Triggered child deployment run: {child_run.id}") await wait_for_flow_run(child_run.id, poll_interval=10) print("Child flow completed successfully") finally: print("Closing Dask client and PBS cluster") client.close() cluster.close()
Copy code
---

# Why this is the recommended approach

## Why not let the master use `@flow(task_runner=DaskTaskRunner(cluster_class=PBSCluster, ...))`?

Because then the cluster is owned by the master flow’s task runner lifecycle.

That makes cross-deployment sharing much less predictable:
- cluster startup is lazy
- cleanup happens when the flow/task runner exits
- the child deployment depends on infrastructure owned by another flow’s runner

## Why explicit `PBSCluster` + `Client` is better

It gives you direct control over:
- when the cluster starts
- when adaptation is configured
- how the scheduler address is read
- when the cluster is torn down

That’s the safest pattern when multiple deployed flow runs need to share one temporary cluster.

---

# Why `with runner:` is safe in the child

In the child:

```python
runner = DaskTaskRunner(address=scheduler_address)
with runner:
    ...
this only manages the child’s connection to the existing cluster. It should not close the master-owned scheduler or PBS workers. By contrast, when the child uses:
Copy code
python
DaskTaskRunner(
    cluster_class=PBSCluster,
    cluster_kwargs=...,
    adapt_kwargs=...,
)
then that child runner does own that temporary cluster and should clean it up on exit — which is exactly what you want for standalone execution. --- # When to use
minimum=1
vs
minimum=0
For your master/child shared-cluster pattern, I’d recommend:
Copy code
python
cluster.adapt(minimum=1, maximum=MAX_WORKERS)
because: - it keeps one worker warm - reduces delay when the child starts submitting work - is usually better if the child begins shortly after the master launches it Use
minimum=0
instead if: - your PBS allocation is scarce - idle workers are too expensive - you can tolerate queue/startup delays --- # Deployment notes For Prefect 3, deploy with
prefect deploy
(verified CLI). Example conceptually:
Copy code
bash
prefect deploy ./child_flow.py:child_flow --name child-deployment
prefect deploy ./master_flow.py:master_flow --name master-deployment
Your
run_deployment(...)
call should use the deployment name in the form:
Copy code
python
name="child-flow/child-deployment"
depending on how that deployment is named in your workspace. --- # Final recommendation summary Use explicit cluster ownership in the master, and conditional runner selection in the child. That gives you: - shared temporary cluster for orchestrated master → child runs - standalone temporary cluster for direct child runs - safe cleanup after the child is done - no accidental teardown by the child when it connects via
address
---
If you want, I can do one more pass and give you a production-hardened version of these two files with: - better error handling - optional timeout for child completion - logging via Prefect logger instead of
print
- and parameter typing/validation cleaned up for direct copy/paste.
s
@Marvin Sure, please provide your specs:
Copy code
- better error handling
- optional timeout for child completion
- logging via Prefect logger instead of print
- and parameter typing/validation cleaned up for direct copy/paste.
m
thought for 16.9 seconds
Below is a production-ready pair that meets your list: - better error handling - optional timeout for child completion - Prefect logger - cleaned-up typing/validation I’m assuming Prefect 3.x. --- child_flow.py
Copy code
python
from pathlib import Path
from typing import Optional

from dask_jobqueue import PBSCluster
from prefect import flow, get_run_logger, task
from prefect_dask import DaskTaskRunner

MAX_WORKERS = 20

pbs_cluster_kwargs: dict = {
    # Replace with your real PBS settings
    # "queue": "myqueue",
    # "account": "myproject",
    # "cores": 4,
    # "memory": "16GB",
    # "walltime": "01:00:00",
    # "job_extra_directives": [...],
}


def build_child_runner(scheduler_address: Optional[str]) -> DaskTaskRunner:
    if scheduler_address:
        return DaskTaskRunner(address=scheduler_address)

    return DaskTaskRunner(
        cluster_class=PBSCluster,
        cluster_kwargs=pbs_cluster_kwargs,
        adapt_kwargs={"minimum": 1, "maximum": MAX_WORKERS},
    )


@task
def do_work(x: int) -> int:
    return x * 2


@flow(
    log_prints=False,
    persist_result=False,
    retries=1,
    retry_delay_seconds=20,
)
def child_flow(
    import_file: str,
    working_dir: Optional[Path] = None,
    overwrite: bool = False,
    scheduler_address: Optional[str] = None,
) -> list[int]:
    logger = get_run_logger()

    if not import_file.strip():
        raise ValueError("`import_file` must be a non-empty string.")

    if working_dir is not None and not isinstance(working_dir, Path):
        working_dir = Path(working_dir)

    runner = build_child_runner(scheduler_address)

    if scheduler_address:
        <http://logger.info|logger.info>(
            "Connecting child flow to existing Dask scheduler at %s",
            scheduler_address,
        )
    else:
        <http://logger.info|logger.info>("No scheduler address provided; creating temporary PBS-backed Dask cluster.")

    try:
        with runner:
            futures = [runner.submit(do_work, {"x": i}) for i in range(10)]
            results = [future.result() for future in futures]

        <http://logger.info|logger.info>("Child flow completed successfully with %d results.", len(results))
        return results

    except Exception:
        logger.exception("Child flow failed while executing work on Dask.")
        raise
--- master_flow.py ```python import asyncio import time from pathlib import Path from typing import Optional from uuid import UUID from dask_jobqueue import PBSCluster from distributed import Client from prefect import flow, get_run_logger from prefect.client.orchestration import get_client from prefect.client.schemas.objects import StateType from prefect.deployments.flow_runs import run_deployment MAX_WORKERS = 20 pbs_cluster_kwargs: dict = { # Replace with your real PBS settings # "queue": "myqueue", # "account": "myproject", # "cores": 4, # "memory": "16GB", # "walltime": "010000", # "job_extra_directives": [...], } FINAL_STATES = { StateType.COMPLETED, StateType.FAILED, StateType.CANCELLED, StateType.CRASHED, } async def wait_for_flow_run( flow_run_id: UUID, timeout_seconds: Optional[float] = None, poll_interval_seconds: float = 10, ) -> None: start_time = time.monotonic() async with get_client() as client: while True: flow_run = await client.read_flow_run(flow_run_id) state = flow_run.state if state is not None and state.type in FINAL_STATES: if state.type != StateType.COMPLETED: raise RuntimeError( f"Child flow run {flow_run_id} finished in state {state.type.value}." ) return if timeout_seconds is not None: elapsed = time.monotonic() - start_time if elapsed > timeout_seconds: raise TimeoutError( f"Timed out waiting for child flow run {flow_run_id} "
f"after {timeout_seconds} seconds." ) await asyncio.sleep(poll_interval_seconds) @flow(log_prints=False) async def master_flow( import_file: str, working_dir: Optional[Path] = None, overwrite: bool = False, child_deployment_name: str = "child-flow/child-deployment", child_timeout_seconds: Optional[float] = None, poll_interval_seconds: float = 10, ) -> None: logger = get_run_logger() if not import_file.strip(): raise ValueError("`import_file` must be a non-empty string.") if not child_deployment_name.strip(): raise ValueError("`child_deployment_name` must be a non-empty string.") if poll_interval_seconds <= 0: raise ValueError("`poll_interval_seconds` must be > 0.") if child_timeout_seconds is not None and child_timeout_seconds <= 0: raise ValueError("`child_timeout_seconds` must be > 0 when provided.") if working_dir is not None and not isinstance(working_dir, Path): working_dir = Path(working_dir) cluster = None client = None try: logger.info("Creating temporary PBS-backed Dask cluster.") cluster = PBSCluster(**pbs_cluster_kwargs) cluster.adapt(minimum=1, maximum=MAX_WORKERS) logger.info("Connecting Dask client to temporary cluster.") client = Client(cluster) scheduler_address = client.scheduler.address logger.info("Created shared Dask scheduler at %s", scheduler_address) child_parameters = { "import_file": import_file, "working_dir": str(working_dir) if working_dir else None, "overwrite": overwrite, "scheduler_address": scheduler_address, } logger.info( "Triggering child deployment %s with shared scheduler address.", child_deployment_name, ) child_run = await run_deployment( name=child_deployment_name, parameters=child_parameters, timeout=0, poll_interval=5, ) logger.info("Triggered child flow run %s", child_run.id) await wait_for_flow_run( flow_run_id=child_run.id, timeout_seconds=child_timeout_seconds, poll_interval_seconds=poll_interval_seconds, ) logger.info("Child flow run %s completed successfully.", child_run.id) except Exception: logger.exception("Master flow failed while managing shared Dask cluster lifecycle.") raise finally: logger.info("Cleaning up Dask resources.") if client is not None: try: client.close() logger.info("Closed Dask client.") except Exception: logger.exception("Failed to close Dask client cleanly.") if cluster is not None: try: cluster.close() logger.info("Closed PBS cluster.") except Exception: logger.exception("Failed to close PBS cluster cleanly.")
Copy code
---

A few notes on these specs:

## 1. Timeout behavior
If you set `child_timeout_seconds`, the master will stop waiting and raise `TimeoutError`. It will still enter `finally` and attempt to close the Dask client and cluster.

## 2. Child standalone behavior
If you run the child deployment directly without `scheduler_address`, it will create its own temporary `PBSCluster` and clean it up when the runner context exits.

## 3. Passing `working_dir`
I convert `working_dir` to `str` in the master when passing deployment parameters because deployment parameters are safest when JSON-serializable. The child converts it back to `Path` if needed.

## 4. Shared cluster safety
When the child uses:

```python
DaskTaskRunner(address=scheduler_address)
the child runner should only manage its own connection, not the master-owned cluster. ## 5. One thing I’d strongly recommend testing
Because PBS/Dask-jobqueue environments vary, test this flow with a small workload first to confirm: - workers can connect back to the scheduler - the scheduler address is reachable from the child deployment environment - cluster cleanup behaves the way you expect on your HPC system Useful links: - Prefect Dask integration: prefect-dask docs - Prefect deployments: deployments docs If you want, I can also give you: - a matching
prefect.yaml
- or the exact
prefect deploy
commands for these two flows.
s
@Marvin I went with this approach in managing the cluster from the master flow and passing to the child flow:
Copy code
python
@flow
def master_flow():
    with PBSCluster(...) as cluster:
        cluster.adapt(minimum=1, maximum=MAX_WORKERS)
        with Client(cluster) as client:
            scheduler_address = client.scheduler.address
            <http://logger.info|logger.info>("Created shared Dask scheduler at %s", scheduler_address)
            <http://logger.info|logger.info>("Dask dashboard available at %s", d_client.dashboard_link)
            ...
But I am seeng a problem with:
Copy code
cluster.adapt(minimum=1, maximum=MAX_WORKERS)
This is not behaving the same as done via:
Copy code
DaskTaskRunner(
        cluster_class=PBSCluster,  # type: ignore[arg-type]
        cluster_kwargs=pbs_cluster_kwargs,
        adapt_kwargs={"maximum": MAX_WORKERS}
    )
If use:
Copy code
cluster.adapt(minimum=X...
The cluster opens exactly X workers on my PBS scheduler. So, it is not using adaptive scaling. And if I instead use:
Copy code
cluster.adapt(maximum=X)
Work is donde but I am unable to track workers/nodes on my PBS scheduler. I am completely blind as to how many dask workers/pbs nodes are doing any work. Also, with either of these options with the master flow managing the cluster, I lose observability on my Dask dashboard, I can no longer track the Prefect tasks.
m
thought for 154.7 seconds
What you’re seeing makes sense, and there are really two separate issues here: 1. Dask adaptive scaling semantics on
PBSCluster
2. Loss of Prefect task observability when you moved to explicit cluster/client management --- # 1) Why
cluster.adapt(minimum=X, maximum=Y)
immediately opens
X
PBS jobs That part is expected Dask behavior. When you do:
Copy code
python
cluster.adapt(minimum=1, maximum=MAX_WORKERS)
you are telling Dask: - always keep at least 1 worker alive - scale up to at most
MAX_WORKERS
So Dask will immediately try to provision the minimum. On PBS, that means it will submit PBS jobs right away. So: -
minimum=1
→ 1 worker/job starts immediately -
minimum=4
→ 4 workers/jobs start immediately -
minimum=0
→ start empty, scale up only when work appears That is not a Prefect difference — it’s the meaning of Dask adaptive scaling. ## Why your old
DaskTaskRunner(... adapt_kwargs={"maximum": MAX_WORKERS})
felt different Because that was effectively the same as adaptive scaling with no minimum floor, i.e. start empty and grow on demand. So the closest explicit equivalent is:
Copy code
python
cluster.adapt(maximum=MAX_WORKERS)
or explicitly:
Copy code
python
cluster.adapt(minimum=0, maximum=MAX_WORKERS)
If you want “true adaptive scaling from zero”, use
minimum=0
, not
minimum=1
. --- # 2) Why you lost Prefect task observability on the Dask dashboard This is the more important part. When you switched to managing the cluster with:
Copy code
python
with PBSCluster(...) as cluster:
    with Client(cluster) as client:
        ...
you introduced a plain Dask
distributed.Client
. That client is not the same thing as Prefect’s Dask integration client path. Prefect task observability on Dask works best when tasks are submitted through
DaskTaskRunner
, which internally uses Prefect’s own Dask client wrapper/instrumentation. ## Short version -
distributed.Client(...)
= plain Dask client -
DaskTaskRunner(...)
= Prefect-aware submission path If you start using plain Dask client mechanics directly, you can lose the mapping between: - Prefect task runs - Dask worker execution - dashboard-visible task naming/context That’s why you’re seeing reduced visibility. --- # 3) Recommended correction ## Best pattern for your use case Use the master to create and own the cluster, but still use Prefect’s
DaskTaskRunner
for actual Prefect task submission. The key improvement is: - master creates
PBSCluster
- child connects with
DaskTaskRunner(address=...)
- child submits Prefect tasks through the runner - do not submit actual work through plain
distributed.Client.submit(...)
That preserves Prefect task instrumentation in the child. --- # 4) Important nuance: what the Dask dashboard can and cannot show There are two different kinds of “observability” here: ## A. Prefect observability This is: - flow runs - task runs - task states - retries/failures - relationships between tasks and flows That lives in Prefect. ## B. Dask dashboard observability This is: - workers - tasks on workers - memory usage - scheduler activity - task stream That lives in Dask. When using shared clusters across separate deployments, you should expect: - Prefect UI to show flow/task state correctly if tasks are submitted through
DaskTaskRunner
- Dask dashboard to show Dask execution activity - but not necessarily the exact same rich Prefect task labeling/structure you got in the single-flow single-runner pattern That’s a limitation of splitting orchestration across separate deployed runs. --- # 5) Why
adapt(maximum=X)
can feel “blind” on PBS If you do:
Copy code
python
cluster.adapt(maximum=X)
then Dask may scale up and down dynamically depending on backlog. On PBS systems that often means: - jobs may be submitted and complete quickly - workers may come and go - the scheduler/UI timing may make it hard to see stable worker counts
- the PBS queue may not show what you expect if jobs launch and drain quickly So you’re not necessarily “not using workers” — it may just be transient and harder to inspect. If you want more stable visibility for debugging, temporarily use:
Copy code
python
cluster.adapt(minimum=1, maximum=X)
or even manually scale for debugging:
Copy code
python
cluster.scale(2)
This is often easier while validating behavior. --- # 6) Final recommendation for your architecture Given your goals, I’d recommend this: ## Master flow - creates
PBSCluster
- uses
cluster.adapt(minimum=0, maximum=MAX_WORKERS)
for production - logs scheduler address and dashboard link - triggers child deployment - waits for child completion - closes cluster ## Child flow - if
scheduler_address
provided: - use
DaskTaskRunner(address=scheduler_address)
- else: - create its own temporary cluster with
DaskTaskRunner(cluster_class=PBSCluster, ...)
- submit Prefect tasks normally via
.submit()
## For observability/debugging When validating behavior, temporarily switch production adaptive scaling to one of these: ### Debug-friendly adaptive mode
Copy code
python
cluster.adapt(minimum=1, maximum=MAX_WORKERS)
### Fully manual debug mode
Copy code
python
cluster.scale(2)
Manual scaling is often the easiest way to confirm: - workers are launching - PBS jobs are visible - child flow can connect - dashboard works Then switch back to adaptive once confirmed. --- # 7) My practical advice ## If your priority is clean Prefect task observability The simplest and best experience is still:
Copy code
python
@flow(
    task_runner=DaskTaskRunner(
        cluster_class=PBSCluster,
        cluster_kwargs=pbs_cluster_kwargs,
        adapt_kwargs={"maximum": MAX_WORKERS},
    )
)
But that only works cleanly when one flow run owns the cluster. ## If your priority is share one temporary cluster across deployments Then your explicit-cluster approach is still the right direction, but accept these tradeoffs: - adaptive scaling behavior is pure Dask behavior -
minimum=X
means immediately provision X workers - dashboard/task observability may not feel identical to the single-runner case - debugging is easier with manual
cluster.scale(...)
first --- # 8) Concrete recommendation for the master For production:
Copy code
python
cluster = PBSCluster(**pbs_cluster_kwargs)
cluster.adapt(minimum=0, maximum=MAX_WORKERS)
client = Client(cluster)

<http://logger.info|logger.info>("Created shared Dask scheduler at %s", client.scheduler.address)
<http://logger.info|logger.info>("Dask dashboard available at %s", client.dashboard_link)
For debugging/visibility:
Copy code
python
cluster = PBSCluster(**pbs_cluster_kwargs)
cluster.scale(2)
client = Client(cluster)

<http://logger.info|logger.info>("Created shared Dask scheduler at %s", client.scheduler.address)
<http://logger.info|logger.info>("Dask dashboard available at %s", client.dashboard_link)
That second version is often much easier to reason about on PBS. --- # 9) One likely source of confusion in your current setup If you are expecting the master flow’s Dask dashboard session to show all Prefect task metadata for the child deployment’s Prefect tasks exactly as before, that may not happen the same way. Why: - the child is a separate flow run - the child creates its own
DaskTaskRunner(address=...)
- task instrumentation is tied to that child flow run context So the Dask cluster is shared, but the Prefect execution context is not “one big flow context”. --- Useful docs: - prefect-dask docs - task runners - Dask adaptive scaling If you want, I can next give you: 1. a revised master/child implementation tuned for better observability, or 2. a debug version that uses
cluster.scale(...)
first and then shows how to switch back to adaptive.
s
@Marvin Mmmm. . . I can confirm that using either of these two approaches does not work well, not only do we lose observability, but I am inclined to say that processing is never assigned to any of the PBS cluster nodes; processing node(s) are assigned by the scheduler but no processing occurs there, and instead all happens on my current login compute node. So, these do not work as expected: Notice that my tests here are done on my child flow, no sharing of the cluster occurs in this case.
Copy code
def build_child_runner(scheduler_address: Optional[str]) -> DaskTaskRunner:
    if scheduler_address:
        return DaskTaskRunner(address=scheduler_address)

    return DaskTaskRunner(
        cluster_class=PBSCluster,
        cluster_kwargs=pbs_cluster_kwargs,
        adapt_kwargs={"minimum": 1, "maximum": MAX_WORKERS},
    )


@task
def do_work(x: int) -> int:
    return x * 2


@flow(
    log_prints=False,
    persist_result=False,
    retries=1,
    retry_delay_seconds=20,
)
def child_flow(
    import_file: str,
    working_dir: Optional[Path] = None,
    overwrite: bool = False,
    scheduler_address: Optional[str] = None,
) -> list[int]:
    logger = get_run_logger()

    if not import_file.strip():
        raise ValueError("`import_file` must be a non-empty string.")

    if working_dir is not None and not isinstance(working_dir, Path):
        working_dir = Path(working_dir)

    runner = build_child_runner(scheduler_address=None)
    with runner:
        # do processing here
If I instead revert to:
Copy code
python
@flow(
    task_runner=DaskTaskRunner(
        cluster_class=PBSCluster,
        cluster_kwargs=pbs_cluster_kwargs,
        adapt_kwargs={"maximum": MAX_WORKERS},
    )
)
def child_flow(():
    ...
Everything works as expected. So, is there an alternative way to register the cluster that mirrors the behaviour with the decorator (static) approach? Before you had suggested using the
FlowRunContext
object like:
Copy code
python
from prefect import flow, task
from prefect.context import FlowRunContext
from prefect_dask import DaskTaskRunner
from dask_jobqueue import PBSCluster

@task
def warmup():
    return "ok"

@flow(
    task_runner=DaskTaskRunner(
        cluster_class=PBSCluster,
        cluster_kwargs=pbs_cluster_kwargs,
        adapt_kwargs={"maximum": MAX_WORKERS},
    )
)
def child_flow():
    warmup.submit().result()

    task_runner = FlowRunContext.get().task_runner
    scheduler_address = task_runner.client.scheduler.address

    print(f"Dask scheduler address
m
thought for 144.2 seconds
Yes — you’ve now hit the key architectural constraint. The reason this version does not behave like the decorator version is:
Copy code
python
runner = build_child_runner(...)
with runner:
    # do processing here
Entering a
DaskTaskRunner
context manager inside a flow does not change the flow’s actual task runner.
So when you later do:
Copy code
python
my_task.submit()
Prefect is still using the task runner that was attached to the flow when the flow started, not the
runner
you just created locally in the function body. That explains exactly what you observed: - the PBS cluster may get created - workers may even get scheduled - but Prefect tasks still execute via the flow’s original runner - so work stays on the local/login node instead of the PBS workers ## Why the decorator version works This works:
Copy code
python
@flow(
    task_runner=DaskTaskRunner(
        cluster_class=PBSCluster,
        cluster_kwargs=pbs_cluster_kwargs,
        adapt_kwargs={"maximum": MAX_WORKERS},
    )
)
def child_flow():
    ...
because the
DaskTaskRunner
is bound to the flow before execution begins. Then
task.submit()
uses that runner. ## Why
with runner:
inside the flow does not work Because
task.submit()
resolves its runner from the active
FlowRunContext
, which is already established at flow start. So this pattern:
Copy code
python
@flow
def child_flow():
    runner = DaskTaskRunner(...)
    with runner:
        my_task.submit()
does not “register” the runner as the flow’s runner. It just starts a runner object and its resources. It does not replace the active flow task runner. --- # Answer to your question
Is there an alternative way to register the cluster that mirrors the behaviour with the decorator (static) approach?
## Yes — the supported alternative is
flow.with_options(...)
If you need the same behavior as the decorator, but chosen dynamically, the right pattern is:
Copy code
python
child_flow.with_options(task_runner=...)
That creates a new flow object with the desired task runner attached before execution starts. This mirrors the decorator behavior much more closely. --- # Recommended pattern ## Define the flow logic once
Copy code
python
from pathlib import Path
from typing import Optional

from prefect import flow, task, get_run_logger

@task
def do_work(x: int) -> int:
    return x * 2


@flow(
    log_prints=False,
    persist_result=False,
    retries=1,
    retry_delay_seconds=20,
)
def child_flow(
    import_file: str,
    working_dir: Optional[Path] = None,
    overwrite: bool = False,
    scheduler_address: Optional[str] = None,
) -> list[int]:
    logger = get_run_logger()

    <http://logger.info|logger.info>("Starting child flow")

    futures = [do_work.submit(i) for i in range(10)]
    results = [f.result() for f in futures]

    <http://logger.info|logger.info>("Completed child flow")
    return results
## Build the runner separately
Copy code
python
from typing import Optional
from dask_jobqueue import PBSCluster
from prefect_dask import DaskTaskRunner

MAX_WORKERS = 20

pbs_cluster_kwargs = {
    # your settings
}

def build_child_runner(scheduler_address: Optional[str]) -> DaskTaskRunner:
    if scheduler_address:
        return DaskTaskRunner(address=scheduler_address)

    return DaskTaskRunner(
        cluster_class=PBSCluster,
        cluster_kwargs=pbs_cluster_kwargs,
        adapt_kwargs={"maximum": MAX_WORKERS},
    )
## Create a configured flow before calling it
Copy code
python
def run_child_flow(
    import_file: str,
    working_dir: Optional[Path] = None,
    overwrite: bool = False,
    scheduler_address: Optional[str] = None,
):
    configured_flow = child_flow.with_options(
        task_runner=build_child_runner(scheduler_address)
    )

    return configured_flow(
        import_file=import_file,
        working_dir=working_dir,
        overwrite=overwrite,
        scheduler_address=scheduler_address,
    )
That is the dynamic equivalent of the decorator approach. ---
# Important limitation for deployments This works well when you are calling the flow object directly in Python. But if
child_flow
is being run as a deployment entrypoint, then the deployment points to a specific flow object imported from a file. So for deployments,
with_options(...)
is usually not the cleanest top-level deployment pattern unless the configured flow is what the deployment entrypoint actually exposes. That means for deployment-based usage, the cleanest options are usually: ## Option A: Two deployment entrypoints One flow for shared cluster, one flow for standalone cluster. ## Option B: A factory/wrapper flow entrypoint Expose an entrypoint flow that already has the correct task runner attached. --- # Best practical recommendation for your case Given your PBS behavior and the fact that the static decorator approach is the one that actually works reliably, I recommend: ## For standalone child runs Keep the static decorator approach:
Copy code
python
@flow(
    task_runner=DaskTaskRunner(
        cluster_class=PBSCluster,
        cluster_kwargs=pbs_cluster_kwargs,
        adapt_kwargs={"maximum": MAX_WORKERS},
    )
)
def child_flow_standalone(...):
    ...
## For shared-cluster runs Create a second flow entrypoint that uses
address=...
statically or via
with_options(...)
before execution starts. For example:
Copy code
python
@flow
def child_flow_logic(...):
    futures = [do_work.submit(i) for i in range(10)]
    return [f.result() for f in futures]
Copy code
python
def child_flow_from_address(scheduler_address: str):
    configured = child_flow_logic.with_options(
        task_runner=DaskTaskRunner(address=scheduler_address)
    )
    return configured
Then your deployment entrypoint can be a wrapper function/flow that is already configured correctly. --- # Reading the scheduler address from the working static-decorator flow Yes — this part is good. If the static-decorator version works, you can absolutely read the scheduler address from the active flow runner via
FlowRunContext
. Example:
Copy code
python
from prefect import flow, task, get_run_logger
from prefect.context import FlowRunContext
from prefect_dask import DaskTaskRunner
from dask_jobqueue import PBSCluster

@task
def warmup():
    return "ok"

@flow(
    task_runner=DaskTaskRunner(
        cluster_class=PBSCluster,
        cluster_kwargs=pbs_cluster_kwargs,
        adapt_kwargs={"maximum": MAX_WORKERS},
    )
)
def child_flow():
    logger = get_run_logger()

    warmup.submit().result()

    task_runner = FlowRunContext.get().task_runner
    scheduler_address = task_runner.client.scheduler.address

    <http://logger.info|logger.info>("Dask scheduler address: %s", scheduler_address)
That is a supported/useful way to read the active scheduler address from a correctly configured flow. But it does not mean you can swap runners mid-flow. --- # Final takeaway ## What does not work
Copy code
python
@flow
def child_flow():
    runner = DaskTaskRunner(...)
    with runner:
        my_task.submit()
because
task.submit()
still uses the flow’s original task runner. ## What does work
Copy code
python
configured_flow = child_flow.with_options(task_runner=DaskTaskRunner(...))
configured_flow(...)
or a static decorator:
Copy code
python
@flow(task_runner=DaskTaskRunner(...))
def child_flow():
    ...
--- # My final recommendation For deployments, I would now suggest one of these two designs: ### Simplest and most reliable Two separate child flow entrypoints: -
child_flow_standalone
with
cluster_class=PBSCluster
-
child_flow_shared
with
address=...
### More reusable One shared logic flow plus two thin wrappers, each with the right task runner attached before execution starts. If you want, I can write the final deployment-ready code for that two-wrapper pattern so you can: - keep the working static PBS behavior - add a shared-cluster entrypoint - and avoid the broken
with runner:
pattern entirely.
s
@Marvin Yes, please elaborate on this recipe:
Copy code
### Simplest and most reliable
Two separate child flow entrypoints:
- child_flow_standalone with cluster_class=PBSCluster
- child_flow_shared with address=...

### More reusable
One shared logic flow plus two thin wrappers, each with the right task runner attached before execution starts.
If you want, I can write the final deployment-ready code for that two-wrapper pattern so you can:
- keep the working static PBS behavior
- add a shared-cluster entrypoint
- and avoid the broken with runner: pattern entirely.
Noting that I still have in mind the design of a child deployment connecting to a master cluster that is managed (I guess statically) by the master deployment.
m
thought for 29.3 seconds
Yes — this is the pattern I’d recommend now. Given what you observed, I would stop trying to swap task runners from inside a running flow. For Prefect +
prefect-dask
, the reliable pattern is: - attach the runner before flow execution starts - keep the child’s task runner static for that entrypoint - use separate entrypoints when you need different runner behavior That still fits your original goal: - master deployment owns a shared cluster - child deployment can connect to that master-owned cluster - child deployment can also be run directly and create its own PBS cluster --- # Recommended architecture ## Design Use: - one shared implementation function for your business logic - two thin child flow entrypoints: -
child_flow_standalone
-
child_flow_shared
- one master flow that creates the cluster and triggers
child_flow_shared
This gives you: - the same “static” task-runner behavior that already works for you - a clean deployment model - no unsupported
with runner:
tricks - easy operational separation --- # Why two child entrypoints is the best fit Because
DaskTaskRunner(address=...)
and
DaskTaskRunner(cluster_class=PBSCluster, ...)
are different execution modes. Trying to unify them in a single deployed entrypoint is awkward because: - the runner must be known at flow start - deployments point to a concrete flow object - runtime parameter-based runner switching inside the flow does not work So instead, make the runner choice part of which flow entrypoint you deploy. --- # Final recipe ## File:
child_flows.py
This file contains: - common task(s) - common implementation logic - standalone entrypoint - shared-cluster entrypoint ```python from pathlib import Path from typing import Optional from prefect import flow, task, get_run_logger from prefect_dask import DaskTaskRunner from dask_jobqueue import PBSCluster MAX_WORKERS = 20 pbs_cluster_kwargs: dict = { # Replace with your real settings # "queue": "myqueue", # "account": "myproject", # "cores": 4, # "memory": "16GB", # "walltime": "010000", # "job_extra_directives": [...], } @task def do_work(x: int) -> int: return x * 2 def _run_child_logic( import_file: str, working_dir: Optional[Path] = None, overwrite: bool = False, ) -> list[int]: logger = get_run_logger() if not import_file.strip(): raise ValueError("`import_file` must be a non-empty string.") if working_dir is not None and not isinstance(working_dir, Path): working_dir = Path(working_dir) logger.info( "Running child logic with import_file=%s working_dir=%s overwrite=%s", import_file, working_dir, overwrite, ) futures = [do_work.submit(i) for i in range(10)] results = [f.result() for f in futures] logger.info("Child logic completed successfully with %d results.", len(results)) return results @flow( name="child-flow-standalone", log_prints=False, persist_result=False, retries=1, retry_delay_seconds=20, task_runner=DaskTaskRunner( cluster_class=PBSCluster, cluster_kwargs=pbs_cluster_kwargs, adapt_kwargs={"maximum": MAX_WORKERS}, ), ) def child_flow_standalone( import_file: str, working_dir: Optional[Path] = None, overwrite: bool = False, ) -> list[int]: return _run_child_logic( import_file=import_file, working_dir=working_dir, overwrite=overwrite, ) # IMPORTANT: # Replace this with the actual scheduler address or hostname/port pattern # that the shared deployment should connect to if you want it statically fixed. # # If the scheduler address is not known until runtime, see the note below: # for dynamic addresses across deployments, this entrypoint alone is not sufficient # unless the deployment runtime can inject the address into the environment or # into deployment/job variables before execution starts.
SHARED_SCHEDULER_ADDRESS = "tcp://your-scheduler-host:8786" @flow( name="child-flow-shared", log_prints=False, persist_result=False, retries=1, retry_delay_seconds=20, task_runner=DaskTaskRunner( address=SHARED_SCHEDULER_ADDRESS, ), ) def child_flow_shared( import_file: str, working_dir: Optional[Path] = None, overwrite: bool = False, ) -> list[int]: return _run_child_logic( import_file=import_file, working_dir=working_dir, overwrite=overwrite, )
Copy code
---

# Critical note about your original design

Your original design is:

- master creates a *temporary* cluster
- master reads that temporary scheduler address
- master launches child deployment
- child connects to *that runtime-created address*

That means the child’s shared-cluster address is *dynamic*, not static.

So the code above with:

```python
task_runner=DaskTaskRunner(address=SHARED_SCHEDULER_ADDRESS)
only works if: - the scheduler address is stable/predictable, or - your environment injects it before the child flow process starts If the address is truly created at runtime and changes every master run, then a completely static
child_flow_shared
decorator is not enough by itself. --- # So what is the most reliable version for dynamic master-owned cluster addresses? There are two realistic deployment-safe patterns: ## Pattern A — stable scheduler endpoint The master-owned scheduler is reachable at a stable address, e.g.: - fixed hostname - fixed port - known service name - reverse tunnel / scheduler service abstraction Then
child_flow_shared
can safely use:
Copy code
python
task_runner=DaskTaskRunner(address="<tcp://stable-hostname:8786>")
This is the easiest and cleanest shared-cluster design. ## Pattern B — runtime-created address The scheduler address is only known after the master starts the cluster. Then you need one of these: - an environment variable available before child flow code starts - a wrapper entrypoint that constructs the flow with
with_options(...)
before execution - or a different orchestration shape, e.g. not a separate child deployment --- # Best deployment-ready pattern for dynamic address This is the reusable two-wrapper pattern I recommend. Instead of trying to make the deployed flow itself switch runners inside the flow, create flow factory wrappers that attach the runner before the flow starts. --- ## File:
child_shared_logic.py
Copy code
python
from pathlib import Path
from typing import Optional

from prefect import flow, task, get_run_logger


@task
def do_work(x: int) -> int:
    return x * 2


@flow(
    name="child-flow-logic",
    log_prints=False,
    persist_result=False,
    retries=1,
    retry_delay_seconds=20,
)
def child_flow_logic(
    import_file: str,
    working_dir: Optional[Path] = None,
    overwrite: bool = False,
) -> list[int]:
    logger = get_run_logger()

    if not import_file.strip():
        raise ValueError("`import_file` must be a non-empty string.")

    if working_dir is not None and not isinstance(working_dir, Path):
        working_dir = Path(working_dir)

    <http://logger.info|logger.info>(
        "Running child flow logic with import_file=%s working_dir=%s overwrite=%s",
        import_file,
        working_dir,
        overwrite,
    )

    futures = [do_work.submit(i) for i in range(10)]
    results = [f.result() for f in futures]

    <http://logger.info|logger.info>("Completed child flow logic with %d results.", len(results))
    return results
--- ## File:
child_standalone.py
```python from pathlib import Path from typing import Optional from dask_jobqueue import PBSCluster from prefect_dask import DaskTaskRunner from child_shared_logic import child_flow_logic MAX_WORKERS = 20 pbs_cluster_kwargs: dict = { # your PBS settings } child_flow_standalone = child_flow_logic.with_options( name="child-flow-standalone", task_runner=DaskTaskRunner( cluster_class=PBSCluster,
cluster_kwargs=pbs_cluster_kwargs, adapt_kwargs={"maximum": MAX_WORKERS}, ), )
Copy code
This gives you a deployed flow object named `child_flow_standalone` that behaves like your working decorator-based version.

---

## File: `child_shared.py`

This is the key pattern for dynamic scheduler address from the master.

```python
import os
from pathlib import Path
from typing import Optional

from prefect import flow
from prefect_dask import DaskTaskRunner

from child_shared_logic import child_flow_logic


@flow(name="child-flow-shared-entrypoint")
def child_flow_shared(
    import_file: str,
    working_dir: Optional[Path] = None,
    overwrite: bool = False,
) -> list[int]:
    scheduler_address = os.environ.get("DASK_SCHEDULER_ADDRESS")

    if not scheduler_address:
        raise ValueError(
            "DASK_SCHEDULER_ADDRESS must be set for child_flow_shared."
        )

    configured_flow = child_flow_logic.with_options(
        name="child-flow-shared",
        task_runner=DaskTaskRunner(address=scheduler_address),
    )

    return configured_flow(
        import_file=import_file,
        working_dir=working_dir,
        overwrite=overwrite,
    )
### Why this works better Here: - the wrapper entrypoint starts first - it reads the scheduler address - it builds a new configured flow with the correct
task_runner
- then it calls that configured flow So the actual logic flow starts with the correct runner already attached. This avoids the broken pattern of trying to swap runners from inside an already-running task-submitting flow. --- # Master flow for the dynamic shared-cluster case Now the master can: 1. create
PBSCluster
2. get
scheduler_address
3. trigger the
child_flow_shared
deployment 4. inject
DASK_SCHEDULER_ADDRESS
into the child run environment 5. wait for child completion 6. clean up cluster ## Important caveat Whether you can inject an env var per deployment run depends on your deployment/work-pool setup. In Prefect 3,
run_deployment(...)
supports
job_variables
, which is the right place to override infra runtime settings where supported. I verified
run_deployment
includes
job_variables
. So conceptually:
Copy code
python
await run_deployment(
    name="child-flow-shared/child-shared-deployment",
    parameters={
        "import_file": import_file,
        "working_dir": str(working_dir) if working_dir else None,
        "overwrite": overwrite,
    },
    job_variables={
        "env": {
            "DASK_SCHEDULER_ADDRESS": scheduler_address,
        }
    },
)
### Important note Whether
job_variables={"env": ...}
is the exact shape you need depends on the worker/work-pool type. For process workers, job variable support can vary by base job template. So this is the right pattern, but the exact job variable schema may need to match your work pool. --- # Full conceptual master flow ```python from distributed import Client from dask_jobqueue import PBSCluster from prefect import flow, get_run_logger from prefect.deployments.flow_runs import run_deployment MAX_WORKERS = 20 pbs_cluster_kwargs = { # your PBS settings } @flow(name="master-flow") async def master_flow(import_file: str): logger = get_run_logger() with PBSCluster(**pbs_cluster_kwargs) as cluster: cluster.adapt(maximum=MAX_WORKERS) with Client(cluster) as client: scheduler_address = client.scheduler.address logger.info("Created shared Dask scheduler at %s", scheduler_address) logger.info("Dask dashboard available at %s", client.dashboard_link) child_run = await run_deployment( name="child-flow-shared/child-shared-deployment", parameters={ "import_file": import_file, }, job_variables={ "env": { "DASK_SCHEDULER_ADDRESS": scheduler_address, } },
timeout=0, poll_interval=5, ) logger.info("Triggered child flow run %s", child_run.id) # wait for child completion here before leaving the context
Copy code
---

# Final recommendation hierarchy

## *Best if possible*
Use a *stable scheduler address* for the shared cluster.
Then `child_flow_shared` can just use a static decorator with `address=...`.

## *Best for dynamic temporary master-owned clusters*
Use:
- one common logic flow
- one standalone configured entrypoint
- one shared wrapper entrypoint that reads env and calls `with_options(...)`

That is the most realistic way to preserve correct Prefect/Dask behavior while still handling a runtime-generated scheduler address.

## *Avoid*
Do not do this:
```python
@flow
def child_flow():
    runner = DaskTaskRunner(...)
    with runner:
        do_work.submit(...)
because it does not change the active flow task runner. --- # One more honest caveat Because you’re on a process worker and an HPC/PBS environment, the biggest operational unknown is whether your deployment infrastructure can reliably inject the runtime scheduler address into the child process environment before the child flow starts. If it cannot, then the “separate child deployment connects to temporary master cluster” design becomes fragile, and the better design may be: - keep the child as a subflow or imported callable inside the master-run context, or - use a stable shared Dask scheduler service instead of a per-master temporary one --- Useful links: - Prefect Dask integration: prefect-dask docs - Prefect deployment docs: Prefect 3 deployments - Prefect task runners: task runners If you want, I can next give you a fully copy-pasteable 3-file implementation for: -
master_flow.py
-
child_shared_logic.py
-
child_entrypoints.py
with the dynamic env-based shared-cluster approach and improved logging/error handling.
s
@Marvin On this observation:
Copy code
Because you're on a process worker and an HPC/PBS environment, the biggest operational unknown is whether your deployment infrastructure can reliably inject the runtime scheduler address into the child process environment before the child flow starts
What's wrong with the original idea of passing the scheduler address via simple flow runtime paramerer from the master to the child deployment, something like: I don't believe there is a need to use deployment environment variables for this purpose
Copy code
@flow(
name="master-flow",
task_runner=DaskTaskRunner(
        cluster_class=PBSCluster,
        cluster_kwargs=pbs_cluster_kwargs,
        adapt_kwargs={"maximum": MAX_WORKERS},
    )
)
async def master_flow(import_file: str):
    logger = get_run_logger()
    task_runner = FlowRunContext.get().task_runner
    scheduler_address = task_runner.client.scheduler.address
    <http://logger.info|logger.info>("Created shared Dask scheduler at %s", scheduler_address)
    <http://logger.info|logger.info>("Dask dashboard available at %s", task_runner.client.dashboard_link)
    
     child_run = await run_deployment(
                name="child-flow-shared/child-shared-deployment",
                parameters={
                    "import_file": import_file,
                    "scheduler_address": scheduler_address 
                },
                timeout=0,
                poll_interval=5,
            )
Considering the above, please go ahead with providing these recipes.
Copy code
If you want, I can next give you a fully copy-pasteable 3-file implementation for:
- master_flow.py
- child_shared_logic.py
- child_entrypoints.py
m
thought for 20.7 seconds
You’re right: passing the scheduler address as a deployment parameter is fine. There’s nothing inherently wrong with:
Copy code
python
await run_deployment(
    name="child-flow-shared/child-shared-deployment",
    parameters={
        "import_file": import_file,
        "scheduler_address": scheduler_address,
    },
)
In fact, for your case, that’s cleaner than env injection. ## The real constraint is not parameter passing The real constraint is: the child flow’s
DaskTaskRunner(address=...)
must be attached before that child flow starts submitting tasks.
So: - yes to passing
scheduler_address
as a flow/deployment parameter - no to trying to instantiate a new runner inside the same already-running task-submitting flow and expecting
task.submit()
to switch over That’s why the wrapper pattern still matters. --- # What this means for the final design You can absolutely do: - master flow gets
scheduler_address
- master calls
run_deployment(..., parameters={"scheduler_address": scheduler_address})
- child entrypoint receives
scheduler_address
- child entrypoint configures the real task-submitting flow with
with_options(task_runner=DaskTaskRunner(address=scheduler_address))
- then calls that configured flow That is the right parameter-driven version. --- # Important subtlety If your child deployed flow is itself the wrapper flow, and inside it you call another configured flow, that can work. The reason is: - the wrapper flow itself doesn’t submit the Dask tasks - it creates a second flow object with the desired runner - that configured flow is the one whose
task.submit()
calls use Dask This is very different from the broken pattern:
Copy code
python
@flow
def child_flow():
    runner = DaskTaskRunner(...)
    with runner:
        do_work.submit(...)
--- # Final copy-pasteable recipe Below is the 3-file version using: - master-managed cluster - scheduler address passed as a runtime parameter - one shared child logic flow - two deployment entrypoints - standalone PBS cluster - shared external scheduler This is the cleanest version of your original idea. --- ##
child_shared_logic.py
Copy code
python
from pathlib import Path
from typing import Optional

from prefect import flow, task, get_run_logger


@task
def do_work(x: int) -> int:
    return x * 2


@task
def warmup() -> str:
    return "ok"


@flow(
    name="child-flow-logic",
    log_prints=False,
    persist_result=False,
    retries=1,
    retry_delay_seconds=20,
)
def child_flow_logic(
    import_file: str,
    working_dir: Optional[Path] = None,
    overwrite: bool = False,
) -> list[int]:
    logger = get_run_logger()

    if not import_file or not import_file.strip():
        raise ValueError("`import_file` must be a non-empty string.")

    if working_dir is not None and not isinstance(working_dir, Path):
        working_dir = Path(working_dir)

    <http://logger.info|logger.info>(
        "Running child flow logic with import_file=%s working_dir=%s overwrite=%s",
        import_file,
        str(working_dir) if working_dir else None,
        overwrite,
    )

    # Optional warmup to force Dask runner/client initialization
    warmup.submit().result()

    futures = [do_work.submit(i) for i in range(10)]
    results = [future.result() for future in futures]

    <http://logger.info|logger.info>("Child flow logic completed successfully with %d results.", len(results))
    return results
--- ##
child_entrypoints.py
```python from pathlib import Path from typing import Optional from dask_jobqueue import PBSCluster from prefect import flow, get_run_logger from prefect_dask import DaskTaskRunner from child_shared_logic import child_flow_logic MAX_WORKERS = 20 pbs_cluster_kwargs: dict = { # Replace with your real PBS settings # "queue": "myqueue", # "account": "myproject", # "cores": 4, # "memory": "16GB", # "walltime": "010000", # "job_extra_directives": [...], } @flow( name="child-flow-standalone-entrypoint",
log_prints=False, persist_result=False, retries=1, retry_delay_seconds=20, ) def child_flow_standalone( import_file: str, working_dir: Optional[Path] = None, overwrite: bool = False, ) -> list[int]: logger = get_run_logger() logger.info("Preparing standalone child flow with temporary PBS-backed Dask cluster.") configured_flow = child_flow_logic.with_options( name="child-flow-standalone", task_runner=DaskTaskRunner( cluster_class=PBSCluster, cluster_kwargs=pbs_cluster_kwargs, adapt_kwargs={"maximum": MAX_WORKERS}, ), ) return configured_flow( import_file=import_file, working_dir=working_dir, overwrite=overwrite, ) @flow( name="child-flow-shared-entrypoint", log_prints=False, persist_result=False, retries=1, retry_delay_seconds=20, ) def child_flow_shared( import_file: str, scheduler_address: str, working_dir: Optional[Path] = None, overwrite: bool = False, ) -> list[int]: logger = get_run_logger() if not scheduler_address or not scheduler_address.strip(): raise ValueError("`scheduler_address` must be a non-empty string.") logger.info( "Preparing shared child flow using external Dask scheduler at %s", scheduler_address, ) configured_flow = child_flow_logic.with_options( name="child-flow-shared", task_runner=DaskTaskRunner(address=scheduler_address), ) return configured_flow( import_file=import_file, working_dir=working_dir, overwrite=overwrite, ) ``` --- ##
master_flow.py
```python import asyncio import time from pathlib import Path from typing import Optional from uuid import UUID from prefect import flow, get_run_logger from prefect.context import FlowRunContext from prefect.client.orchestration import get_client from prefect.client.schemas.objects import StateType from prefect.deployments.flow_runs import run_deployment from prefect_dask import DaskTaskRunner from dask_jobqueue import PBSCluster MAX_WORKERS = 20 pbs_cluster_kwargs: dict = { # Replace with your real PBS settings # "queue": "myqueue", # "account": "myproject", # "cores": 4, # "memory": "16GB", # "walltime": "010000", # "job_extra_directives": [...], } FINAL_STATES = { StateType.COMPLETED, StateType.FAILED, StateType.CANCELLED, StateType.CRASHED, } async def wait_for_flow_run( flow_run_id: UUID, timeout_seconds: Optional[float] = None, poll_interval_seconds: float = 10, ) -> None: start_time = time.monotonic() async with get_client() as client: while True: flow_run = await client.read_flow_run(flow_run_id) state = flow_run.state if state is not None and state.type in FINAL_STATES: if state.type != StateType.COMPLETED: raise RuntimeError( f"Child flow run {flow_run_id} finished in state {state.type.value}." ) return if timeout_seconds is not None: elapsed = time.monotonic() - start_time if elapsed > timeout_seconds: raise TimeoutError( f"Timed out waiting for child flow run {flow_run_id} " f"after {timeout_seconds} seconds." ) await asyncio.sleep(poll_interval_seconds) @flow( name="master-flow", log_prints=False, task_runner=DaskTaskRunner( cluster_class=PBSCluster, cluster_kwargs=pbs_cluster_kwargs, adapt_kwargs={"maximum": MAX_WORKERS}, ), ) async def master_flow( import_file: str, working_dir: Optional[Path] = None, overwrite: bool = False, child_deployment_name: str = "child-flow-shared/child-shared-deployment", child_timeout_seconds: Optional[float] = None,
poll_interval_seconds: float = 10, ) -> None: logger = get_run_logger() if not import_file or not import_file.strip(): raise ValueError("`import_file` must be a non-empty string.") if not child_deployment_name or not child_deployment_name.strip(): raise ValueError("`child_deployment_name` must be a non-empty string.") if poll_interval_seconds <= 0: raise ValueError("`poll_interval_seconds` must be > 0.") if child_timeout_seconds is not None and child_timeout_seconds <= 0: raise ValueError("`child_timeout_seconds` must be > 0 when provided.") flow_ctx = FlowRunContext.get() if flow_ctx is None: raise RuntimeError("Could not access FlowRunContext in master flow.") task_runner = flow_ctx.task_runner scheduler_address = task_runner.client.scheduler.address dashboard_link = task_runner.client.dashboard_link logger.info("Created shared Dask scheduler at %s", scheduler_address) logger.info("Dask dashboard available at %s", dashboard_link) child_run = await run_deployment( name=child_deployment_name, parameters={ "import_file": import_file, "scheduler_address": scheduler_address, "working_dir": str(working_dir) if working_dir else None, "overwrite": overwrite, }, timeout=0, poll_interval=5, ) logger.info("Triggered child flow run %s", child_run.id) await wait_for_flow_run( flow_run_id=child_run.id, timeout_seconds=child_timeout_seconds, poll_interval_seconds=poll_interval_seconds, ) logger.info("Child flow run %s completed successfully.", child_run.id)
Copy code
---

# Why this version matches your intent

## Master
The master uses the *working static decorator pattern*:

```python
task_runner=DaskTaskRunner(
    cluster_class=PBSCluster,
    cluster_kwargs=pbs_cluster_kwargs,
    adapt_kwargs={"maximum": MAX_WORKERS},
)
So it should behave the same way your successful PBS-backed Dask runs already behave. ## Child shared entrypoint The child shared entrypoint receives:
Copy code
python
scheduler_address: str
as a normal Prefect flow parameter. Then it creates:
Copy code
python
child_flow_logic.with_options(
    task_runner=DaskTaskRunner(address=scheduler_address)
)
before executing the task-submitting logic flow. That is the crucial fix. ## Child standalone entrypoint The child standalone entrypoint does the same thing, but with a temporary PBS cluster instead. --- # Deployment suggestions You’d deploy the two entrypoints, not the inner logic flow. Conceptually:
Copy code
bash
prefect deploy ./child_entrypoints.py:child_flow_standalone --name child-standalone-deployment
prefect deploy ./child_entrypoints.py:child_flow_shared --name child-shared-deployment
prefect deploy ./master_flow.py:master_flow --name master-deployment
That matches Prefect 3’s
prefect deploy
CLI. Docs: - Deployments - prefect-dask --- # One honest caveat This is the most faithful version of your original design within Prefect’s task-runner model. But I’d still treat the shared child entrypoint with
with_options(DaskTaskRunner(address=...))
as something to validate carefully on your PBS/process-worker setup, because it introduces: - a wrapper flow calling another configured flow - a shared scheduler across separate deployments - network reachability requirements from child run environment back to the master-created scheduler So this is the right recipe to test next, but I’d test with a small workload first. If you want, I can also give you: - exact
prefect deploy
commands for all three flows - or a
prefect.yaml
example for these entrypoints.
s
@Marvin Please provide
prefect.yaml
example for these entrypoints.
m
thought for 20.0 seconds
Here’s a simple
prefect.yaml
example
for the three entrypoints: -
child_flow_standalone
-
child_flow_shared
-
master_flow
This assumes: - Prefect 3.x - local/project-based deployment - a process work pool - your files are: -
child_shared_logic.py
-
child_entrypoints.py
-
master_flow.py
## Example
prefect.yaml
Copy code
yaml
name: pbs-dask-project
prefect-version: "3"

build: []

push: []

pull: []

deployments:
  - name: child-standalone-deployment
    entrypoint: child_entrypoints.py:child_flow_standalone
    description: Standalone child flow that creates its own temporary PBS-backed Dask cluster.
    work_pool:
      name: my-process-pool
    parameters:
      import_file: ""
      working_dir: null
      overwrite: false

  - name: child-shared-deployment
    entrypoint: child_entrypoints.py:child_flow_shared
    description: Child flow that connects to an existing Dask scheduler passed in at runtime.
    work_pool:
      name: my-process-pool
    parameters:
      import_file: ""
      scheduler_address: ""
      working_dir: null
      overwrite: false

  - name: master-deployment
    entrypoint: master_flow.py:master_flow
    description: Master flow that creates a temporary PBS-backed Dask cluster and triggers the shared child deployment.
    work_pool:
      name: my-process-pool
    parameters:
      import_file: ""
      working_dir: null
      overwrite: false
      child_deployment_name: child-flow-shared/child-shared-deployment
      child_timeout_seconds: null
      poll_interval_seconds: 10
--- # Notes on this config ## 1.
child_deployment_name
In your
master_flow.py
, you used:
Copy code
python
child_deployment_name: str = "child-flow-shared/child-shared-deployment"
So in
prefect.yaml
, I set:
Copy code
yaml
child_deployment_name: child-flow-shared/child-shared-deployment
That assumes: - flow name =
child-flow-shared
- deployment name =
child-shared-deployment
If Prefect registers it differently in your workspace, adjust that string accordingly. ## 2.
scheduler_address
For
child-shared-deployment
, this is intentionally left blank in defaults:
Copy code
yaml
scheduler_address: ""
because the master flow should pass it at runtime via:
Copy code
python
run_deployment(
    ...,
    parameters={
        "scheduler_address": scheduler_address,
    },
)
## 3.
working_dir
I left this as:
Copy code
yaml
working_dir: null
which is usually the safest default in deployment parameters. --- # How to deploy from this file From the project directory:
Copy code
bash
prefect deploy
If needed, you can also target a specific deployment name pattern. Docs: - Prefect deployment docs --- # Optional: include job variables If your process work pool requires specific process-worker job variables, you can add them under each deployment. A conceptual example:
Copy code
yaml
work_pool:
  name: my-process-pool
  job_variables:
    working_dir: /path/to/project
If you want, I can help tailor that to your exact process work pool setup. --- # Slightly more explicit version with tags If you want a more production-ish example: ```yaml name: pbs-dask-project prefect-version: "3" build: [] push: [] pull: [] deployments: - name: child-standalone-deployment entrypoint: child_entrypoints.py:child_flow_standalone description: Standalone child flow that creates its own temporary PBS-backed Dask cluster. tags: - dask - pbs - child - standalone work_pool: name: my-process-pool parameters: import_file: "" working_dir: null overwrite: false - name: child-shared-deployment entrypoint: child_entrypoints.py:child_flow_shared description: Child flow that connects to an existing Dask scheduler passed in at runtime. tags: - dask - pbs - child - shared-cluster work_pool: name: my-process-pool parameters: import_file: ""
scheduler_address: "" working_dir: null overwrite: false - name: master-deployment entrypoint: master_flow.py:master_flow description: Master flow that creates a temporary PBS-backed Dask cluster and triggers the shared child deployment. tags: - dask - pbs - master work_pool: name: my-process-pool parameters: import_file: "" working_dir: null overwrite: false child_deployment_name: child-flow-shared/child-shared-deployment child_timeout_seconds: null poll_interval_seconds: 10
Copy code
---

# One important thing to verify

After deploying, confirm the exact deployment names in the UI or with the CLI, because the string used by `run_deployment(name=...)` must match what Prefect registered.

If the flow name / deployment name combination differs, update:

```python
child_deployment_name="child-flow-shared/child-shared-deployment"
accordingly. --- If you want, I can also provide: - a matching directory layout - exact
prefect deploy --name ...
commands - or a
prefect.yaml
version with schedules, work queues, or process worker job variables.
s
@Marvin I like your recipe in
master_flow.py.
My idea is to collect the metadata right away (
timeout=0
) for all child flow runs launched with
run_deployment
in a loop. What is the most efficient way to then wait for all child runs to finish and collect the child flow run's result? I was initially working with this helper function:
Copy code
async def get_flow_run_result(flow_run_id: UUID) -> object:
    async with get_client() as client:
        flow_run: FlowRun = await client.read_flow_run(flow_run_id)
        return await flow_run.state.result()
And my master flow something like:
Copy code
@flow(
    log_prints=True,
    task_runner=DaskTaskRunner(  # type: ignore[arg-type]
        cluster_class=PBSCluster,
        cluster_kwargs=pbs_cluster_kwargs,
        adapt_kwargs={"maximum": MAX_WORKERS}
    )

)
async def s2_satproc_master(
    files_list: list[str],
    working_dir: Path,
    overwrite: bool = False,
) -> list[str]:# and trigger the child (sub) flows
# passing

    """_summary_

    Args:
        files_list (list[str]): _description_
        working_dir (Path): _description_
        overwrite: _description_
        qvf_stages_out: _description_
        env_state (str, optional): _description_. Defaults to 'dev'.
    """
    logger = get_run_logger()

    # process files needing processing in files_list param
    ...

    flow_ctx = FlowRunContext.get()
    if flow_ctx is None:
        raise RuntimeError("Could not access FlowRunContext in master flow.")

    task_runner = flow_ctx.task_runner
    scheduler_address = task_runner.client.scheduler.address  # type: ignore[attr-defined]
    dashboard_link = task_runner.client.dashboard_link  # type: ignore[attr-defined]

    <http://logger.info|logger.info>("Created shared Dask scheduler at %s", scheduler_address)
    <http://logger.info|logger.info>("Dask dashboard available at %s",   dashboard_link)

    for f_process in to_process:
        <http://logger.info|logger.info>(f"submitting processing for {f_process.infile}")
        # we run several child flows, getting their results concurrently
        main_params = {
            "import_file": f_process.infile,
            "working_dir": working_dir,
            "overwrite": overwrite,
        }

        flow_run = await run_deployment(  # type: ignore[misc]
            name=f"satproc-shared/s2_satproc_shared_dev",
            parameters=main_params,
            job_variables={
                "env": {
                    "DASK_SCHEDULER_ADDRESS": scheduler_address,
                    }
                }
        )
        <http://logger.info|logger.info>(f"submitted flow run - {flow_run.id}")

        files_saved: object = await get_flow_run_result(flow_run.id)

        print(files_saved)

    return files_saved
How can I improve this loop to efficiently collect all child flows as they finish and report results in my
files_saved
list.
m
thought for 115.4 seconds
Yes — your current loop is doing the waiting sequentially, because you launch one child run and then immediately wait for its result before submitting the next. That part is the main thing to change. ## What you want instead 1. Launch all child deployments with
timeout=0
2. Keep the returned `flow_run.id`s 3. Wait for all of them concurrently 4. Collect results as they finish Also, your helper:
Copy code
python
async def get_flow_run_result(flow_run_id: UUID) -> object:
    async with get_client() as client:
        flow_run: FlowRun = await client.read_flow_run(flow_run_id)
        return await flow_run.state.result()
is incomplete for two reasons: - it reads the flow run only once, so the run may not be finished yet -
state.result()
only works if the final state/result is available; if the run is still running, this won’t do what you want --- # Recommended pattern Use: -
run_deployment(..., timeout=0)
to launch immediately -
wait_for_flow_run(...)
to efficiently wait for completion -
asyncio.gather(...)
or
asyncio.as_completed(...)
to manage many child runs concurrently The research indicates
wait_for_flow_run(...)
is preferable to manual polling because it’s more efficient than a naive
read_flow_run()
loop. --- # Best version for your use case Since you want to: - submit many child runs in a loop - report results as they complete - collect them into
files_saved
I’d use
asyncio.as_completed(...)
. That way: - all children run concurrently - you can log each result as soon as it finishes - you don’t block on slower runs before processing faster ones --- # Suggested implementation ## Helper: wait for one child run and fetch its result
Copy code
python
import asyncio
from uuid import UUID

from prefect.client.orchestration import get_client
from prefect.flow_runs import wait_for_flow_run


async def wait_and_get_flow_run_result(flow_run_id: UUID) -> object:
    async with get_client() as client:
        flow_run = await wait_for_flow_run(
            flow_run_id,
            client=client,
            timeout=None,
            log_states=False,
        )

        return await flow_run.state.result(raise_on_failure=True)
## Better master flow pattern ```python import asyncio from pathlib import Path from prefect import flow, get_run_logger from prefect.context import FlowRunContext from prefect.deployments.flow_runs import run_deployment from prefect_dask import DaskTaskRunner from dask_jobqueue import PBSCluster MAX_WORKERS = 20 pbs_cluster_kwargs: dict = { # your PBS settings } async def wait_and_get_flow_run_result(flow_run_id): from prefect.client.orchestration import get_client from prefect.flow_runs import wait_for_flow_run async with get_client() as client: flow_run = await wait_for_flow_run( flow_run_id, client=client, timeout=None, log_states=False, ) return await flow_run.state.result(raise_on_failure=True) @flow( log_prints=True, task_runner=DaskTaskRunner( cluster_class=PBSCluster, cluster_kwargs=pbs_cluster_kwargs, adapt_kwargs={"maximum": MAX_WORKERS}, ), ) async def s2_satproc_master( files_list: list[str], working_dir: Path, overwrite: bool = False, ) -> list[object]: logger = get_run_logger() # Build
to_process
from your own logic to_process = files_list flow_ctx = FlowRunContext.get() if flow_ctx is None: raise RuntimeError("Could not access FlowRunContext in master flow.") task_runner = flow_ctx.task_runner scheduler_address = task_runner.client.scheduler.address dashboard_link = task_runner.client.dashboard_link logger.info("Created shared Dask scheduler at %s", scheduler_address) logger.info("Dask dashboard available at %s", dashboard_link) submitted_runs = [] # 1. Submit all child runs first for infile in to_process:
logger.info("Submitting processing for %s", infile) main_params = { "import_file": infile, "working_dir": str(working_dir), "overwrite": overwrite, "scheduler_address": scheduler_address, } flow_run = await run_deployment( name="satproc-shared/s2_satproc_shared_dev", parameters=main_params, timeout=0, poll_interval=5, ) logger.info("Submitted child flow run %s for %s", flow_run.id, infile) submitted_runs.append( { "flow_run_id": flow_run.id, "import_file": infile, } ) # 2. Wait for all concurrently and collect results as they finish async def resolve_one(run_info: dict) -> dict: result = await wait_and_get_flow_run_result(run_info["flow_run_id"]) return { "flow_run_id": run_info["flow_run_id"], "import_file": run_info["import_file"], "result": result, } pending = [asyncio.create_task(resolve_one(run_info)) for run_info in submitted_runs] files_saved = [] for completed in asyncio.as_completed(pending): try: item = await completed logger.info( "Child flow run %s finished for %s", item["flow_run_id"], item["import_file"], ) logger.info("Child result: %s", item["result"]) files_saved.append(item["result"]) except Exception: logger.exception("A child flow run failed.") raise return files_saved
Copy code
---

# Why this is better

## Your original loop
This is effectively sequential:

```python
for f_process in to_process:
    flow_run = await run_deployment(...)
    files_saved = await get_flow_run_result(flow_run.id)
That means: - submit child 1 - wait for child 1 to finish - submit child 2 - wait for child 2 to finish - etc. ## Improved loop This does: - submit all children first - then wait for all concurrently That’s much more efficient. --- # If you do not care about “finish order” If you just want all results at the end, and don’t need per-run completion logging, you can use
asyncio.gather(...)
instead of
as_completed(...)
. Example:
Copy code
python
results = await asyncio.gather(
    *(wait_and_get_flow_run_result(item["flow_run_id"]) for item in submitted_runs)
)
That is slightly simpler, but you lose the ability to process/log each result as soon as it finishes. --- # Important caveat: result persistence Because you are retrieving results from a deployment flow run via API state, the child flow’s returned result needs to be available from the final state. So if you expect:
Copy code
python
return ["file1", "file2"]
from the child flow, make sure the child flow result is persisted/available. If result persistence is disabled, fetching a deployment result later from the API can fail with missing result data. If needed, make that explicit on the child flow:
Copy code
python
@flow(persist_result=True, ...)
def child_flow(...):
    ...
If you currently have
persist_result=False
on the child, that is likely a problem for this pattern. That’s a very important point. --- # I strongly recommend this change for child flows If you want the master to retrieve child flow results later, do not use:
Copy code
python
persist_result=False
on the child flow. Instead, use either the default behavior or explicitly:
Copy code
python
@flow(
    persist_result=True,
    ...
)
def child_flow(...):
    ...
--- # Refined helper with better failure handling You may want this version: ```python from uuid import UUID from prefect.client.orchestration import get_client from prefect.flow_runs import wait_for_flow_run async def wait_and_get_flow_run_result(flow_run_id: UUID) -> object: async with get_client() as client: flow_run = await wait_for_flow_run(
flow_run_id, client=client, timeout=None, log_states=False, ) return await flow_run.state.result(raise_on_failure=True)
Copy code
If you want failures returned instead of raised:

```python
async def wait_and_get_flow_run_result(flow_run_id: UUID) -> object:
    async with get_client() as client:
        flow_run = await wait_for_flow_run(
            flow_run_id,
            client=client,
            timeout=None,
            log_states=False,
        )

        return await flow_run.state.result(raise_on_failure=False)
Then failed runs will return their exception object rather than raising immediately. --- # My recommendation summary For your master flow: - submit all child deployments first with
timeout=0
- use
asyncio.as_completed(...)
to process them as they finish - use
wait_for_flow_run(...)
instead of manual one-shot
read_flow_run(...)
- ensure the child flow persists results if you want to retrieve them later --- Useful links: - Prefect deployments: deployment docs - Prefect Dask integration: prefect-dask docs If you want, I can turn your exact
s2_satproc_master
snippet into a fully cleaned-up final version with: - typed
files_saved
- per-child failure handling - timeout support for all children - and optional “continue on child failure” behavior.
s
@Marvin What does the
while True:
control flow actually does here:
Copy code
async def wait_for_flow_run(
    flow_run_id: UUID,
    timeout_seconds: float | None = None,
    poll_interval_seconds: float = 10,
) -> None:
    start_time = time.monotonic()

    async with get_client() as client:
        while True:
            flow_run = await client.read_flow_run(flow_run_id)
            state = flow_run.state

            if state is not None and state.type in FINAL_STATES:
                if state.type != StateType.COMPLETED:
                    raise RuntimeError(
                        f"Child flow run {flow_run.name} - {flow_run_id} finished in state {state.type.value}."
                    )
                return

            if timeout_seconds is not None:
                elapsed = time.monotonic() - start_time
                if elapsed > timeout_seconds:
                    raise TimeoutError(
                        f"Timed out waiting for child flow run {flow_run.name} - {flow_run_id} "
                        f"after {timeout_seconds} seconds."
                    )

            await asyncio.sleep(poll_interval_seconds)
m
thought for 12.7 seconds
It’s just an indefinite polling loop. The
while True:
means: - keep checking the child flow run state over and over - stop only when one of these happens: - the run completes successfully
return
- the run ends in a failed/crashed/cancelled final state →
raise RuntimeError
- the wait exceeds the timeout →
raise TimeoutError
So this function is basically: 1. read the flow run state 2. if finished, stop 3. if timed out, stop 4. otherwise sleep 5. repeat ## In plain English
Copy code
python
while True:
means “loop forever until something inside explicitly exits the loop.” In your function, the exits are: ### Success exit
Copy code
python
return
### Failure exit
Copy code
python
raise RuntimeError(...)
### Timeout exit
Copy code
python
raise TimeoutError(...)
If none of those happen, it keeps polling. --- # Step-by-step behavior
Copy code
python
async with get_client() as client:
    while True:
Open a Prefect client, then repeatedly do the following. ## 1. Read the current flow run state
Copy code
python
flow_run = await client.read_flow_run(flow_run_id)
state = flow_run.state
## 2. If the run is in a final state, stop looping
Copy code
python
if state is not None and state.type in FINAL_STATES:
If it finished: - if it finished as
COMPLETED
→ return successfully - otherwise → raise an error
Copy code
python
if state.type != StateType.COMPLETED:
    raise RuntimeError(...)
return
## 3. If not finished, check timeout
Copy code
python
if timeout_seconds is not None:
    elapsed = time.monotonic() - start_time
    if elapsed > timeout_seconds:
        raise TimeoutError(...)
If too much time has passed, stop waiting. ## 4. Otherwise sleep and check again
Copy code
python
await asyncio.sleep(poll_interval_seconds)
This prevents hammering the API continuously. Then the loop goes back to the top and repeats. --- # Why
while True
is okay here Because this is a standard “wait until condition is met” loop. It looks infinite, but it’s not meant to run forever in practice — it exits when: - the child run finishes - it fails - or it times out --- # Equivalent conceptual version You can think of it like this:
Copy code
python
done = False
while not done:
    check status
    if success:
        done = True
    elif failed:
        raise error
    elif timed out:
        raise timeout
    else:
        sleep
Using
while True
is just a simpler style when all exits happen from inside the loop. --- # One small improvement You could make the intent a bit clearer by returning the
flow_run
instead of
None
, e.g.:
Copy code
python
async def wait_for_flow_run(...):
    ...
    while True:
        flow_run = await client.read_flow_run(flow_run_id)
        state = flow_run.state

        if state is not None and state.type in FINAL_STATES:
            if state.type != StateType.COMPLETED:
                raise RuntimeError(...)
            return flow_run

        ...
That way the caller gets the final flow run object directly. --- # Important note This is manual polling. Earlier I recommended Prefect’s built-in:
Copy code
python
from prefect.flow_runs import wait_for_flow_run
That helper is generally better than rolling your own polling loop, because it’s designed for this use case. So your custom
while True
loop is valid, but if you want the most idiomatic Prefect approach, prefer the built-in helper. Docs: - Prefect docs - Deployments docs If you want, I can show: - your custom polling loop - and the equivalent built-in
prefect.flow_runs.wait_for_flow_run(...)
side-by-side.
s
@Marvin I am having problems using
<http://asyncio.as|asyncio.as>_completed
and initiating jobs on my
DaskTaskRunner
... This is my design, so far:
Copy code
async def wait_and_get_flow_run_result(flow_run_id: UUID) -> object:
    async with get_client() as client:
        flow_run = await wait_for_flow_run(
            flow_run_id,
            client=client,
            timeout=None,
            log_states=False,
        )

        return await flow_run.state.result(raise_on_failure=False)  # type: ignore[union-attr]


async def resolve_flow_run(run_info: dict) -> dict:
    result = await wait_and_get_flow_run_result(run_info["flow_run_id"])
    return {
        "flow_run_id": run_info["flow_run_id"],
        "import_file": run_info["import_file"],
        "result": result,
    }


# we run the master flow
@flow(
    log_prints=True,
    task_runner=DaskTaskRunner(  # type: ignore[arg-type]
        cluster_class=PBSCluster,
        cluster_kwargs=pbs_cluster_kwargs,
        adapt_kwargs={"maximum": MAX_WORKERS}
    )

)
async def s2_satproc_master(
    files_list: list[str],
    working_dir: Path,
    overwrite: bool = False,
    qvf_stages_out: set[str] = QVF_STAGES_OUT,
    env_state: str = 'dev'
) -> None:# and trigger the child (sub) flows
    """_summary_

    Args:
        files_list (list[str]): _description_
        working_dir (Path): _description_
        overwrite: _description_
        qvf_stages_out: _description_
        env_state (str, optional): _description_. Defaults to 'dev'.
    """
    logger = get_run_logger()

    # some non-DaskTaskRunner prep-logic
    # ...

    # get cluster and share address with child flows
    flow_ctx = FlowRunContext.get()
    if flow_ctx is None:
        raise RuntimeError("Could not access FlowRunContext in master flow.")

    task_runner = flow_ctx.task_runner
    scheduler_address = task_runner.client.scheduler.address  # type: ignore[attr-defined]
    dashboard_link = task_runner.client.dashboard_link  # type: ignore[attr-defined]

    <http://logger.info|logger.info>("Created shared Dask scheduler at %s", scheduler_address)
    <http://logger.info|logger.info>("Dask dashboard available at %s",   dashboard_link)

    # collect all flow run submissions
    submitted_runs: list[dict] = []
    for f_process in to_process:
        <http://logger.info|logger.info>(f"submitting processing for {f_process.infile}")
        # we run several child flows, getting their results concurrently
        main_params = {
            "import_file": f_process.infile,
            "working_dir": working_dir,
            "overwrite": overwrite,
        }

        flow_run = await run_deployment(  # type: ignore[misc]
            name=f"sen2-satproc-shared/s2_satproc_shared_{env_state}",
            parameters=main_params,
            timeout=0,
            job_variables={
                "env": {
                    "DASK_SCHEDULER_ADDRESS": scheduler_address,
                    }
                }
        )
        <http://logger.info|logger.info>(f"submitted flow run {flow_run.name} - {flow_run.id} for {f_process.infile}")

        submitted_runs.append(
            {
                "flow_run_id": flow_run.id,
                "import_file": f_process.infile
            }
        )

    # wait for all concurrently and collect results as they finish
    files_saved: list[str] = []
    failures: list[Exception] = []
    pending = [asyncio.create_task(resolve_flow_run(run_info)) for run_info in submitted_runs]
    for completed in asyncio.as_completed(pending):
        r_d = await completed
        <http://logger.info|logger.info>(
            "Child flow run %s finished for %s.",
            r_d["flow_run_id"], r_d["import_file"]
        )
        result = r_d["result"]
        if not isinstance(result, Exception):
            <http://logger.info|logger.info>(f"files saved: {result=}")
            files_saved.extend(result)
        else:
            <http://logger.info|logger.info>("%s - %s failed with error:", r_d["import_file"], r_d["flow_run_id"])
            logger.warning(result)
            failures.append(result)

    <http://logger.info|logger.info>("Total number of files successfully saved = %s", len(files_saved))
    print(files_saved)

    logger.warning("Number of child flow runs that failed = %s", len(failures))

    return



if __name__ == "__main__":
    # Check if parameters are passed as a command-line argument
    if len(sys.argv) < 2:
        print("Usage: python flows/s2_satproc_master_flow.py '<parameters_as_json>'")
        sys.exit(1)

    # Parse the JSON string passed as the first argument
    parameters = json.loads(sys.argv[1])

    # process_file = 'cgmsre_t55kfa_20260514_ab0.img'
    files_list = parameters.get("files_list")
    working_dir = parameters.get("working_dir", os.getenv("SATPROC_SHARED_DIR"))
    if working_dir:
        working_dir = Path(working_dir)
    # check if boolean param is there as well
    overwrite = parameters.get("overwrite", True)
    qvf_stages_out = parameters.get("qvf_stages_out", QVF_STAGES_OUT)
    env_state = parameters.get("env_state", 'dev')

    asyncio.run(
        s2_satproc_master(
            files_list = files_list,
            working_dir = working_dir,
            overwrite = overwrite,
            qvf_stages_out = qvf_stages_out,
            env_state = env_state
        )
    )
So, my deployment flow run are sent to the Prefect (process) worker, but no Dask cluster is initiated. This is the logic of my child flow deployment:
Copy code
SHARED_SCHEDULER_ADDRESS = os.getenv("DASK_SCHEDULER_ADDRESS")
@flow(
    task_runner=DaskTaskRunner(address=SHARED_SCHEDULER_ADDRESS),  # type: ignore[arg-type]
    log_prints=False,
    persist_result=True,
    retries=1,
    retry_delay_seconds=20,
)
def sen2_satproc_shared(
    import_file: str,
    working_dir: Optional[Path] = None,
    overwrite: bool = False,
) -> list[str]:
    """_summary_

    Args:
        import_file (str): _description_
        working_dir (Optional[Path], optional): _description_. Defaults to None.
        overwrite (bool, optional): _description_. Defaults to False.

    Raises:
        ValueError: _description_

    Returns:
        list[str]: _description_
    """
    logger = get_run_logger()

    if not SHARED_SCHEDULER_ADDRESS or not SHARED_SCHEDULER_ADDRESS.strip():
        raise ValueError("scheduler_address must be a non-empty string.")

    <http://logger.info|logger.info>(
        "Preparing shared child flow using external Dask scheduler at %s",
        SHARED_SCHEDULER_ADDRESS,
    )

    # configured_flow = sen2_satproc.with_options(
    #     task_runner=DaskTaskRunner(address=scheduler_address),  # type: ignore[arg-type]
    # )

    return child_proc(  # this is a plain Python function with the business logic.
        import_file=import_file,
        working_dir=working_dir,
        overwrite=overwrite
    )
m
Your message was too long, here's your message at the allowed limit: I am having problems using
<http //asyncio as|asyncio as>_completed
and initiating jobs on my
DaskTaskRunner
This is my design so far ```async def wait_and_get_flow_run_result(flow_run_id UUID) -> object async with get_client() as client flow_run = await wait_for_flow_run( flow_run_id client=client timeout=None log_states=False ) return await flow_run state result(raise_on_failure=False) # type ignore[union-attr] async def resolve_flow_run(run_info dict) -> dict result = await wait_and_get_flow_run_result(run_info[ flow_run_id ]) return { flow_run_id run_info[ flow_run_id ] import_file run_info[ import_file ] result result } # we run the master flow @flow( log_prints=True task_runner=DaskTaskRunner( # type ignore[arg-type] cluster_class=PBSCluster cluster_kwargs=pbs_cluster_kwargs adapt_kwargs={ maximum MAX_WORKERS} ) ) async def s2_satproc_master( files_list list[str] working_dir Path overwrite bool = False qvf_stages_out set[str] = QVF_STAGES_OUT env_state str = 'dev' ) -> None # and trigger the child (sub) flows summary Args files_list (list[str]) description working_dir (Path) description overwrite description qvf_stages_out description env_state (str optional) description Defaults to 'dev' logger = get_run_logger() # some non-DaskTaskRunner prep-logic # # get cluster and share address with child flows flow_ctx = FlowRunContext get() if flow_ctx is None raise RuntimeError( Could not access FlowRunContext in master flow ) task_runner = flow_ctx task_runner scheduler_address = task_runner client scheduler address # type ignore[attr-defined] dashboard_link = task_runner client dashboard_link # type ignore[attr-defined] <http //logger info|logger info>( Created shared Dask scheduler at %s scheduler_address) <http //logger info|logger info>( Dask dashboard available at %s dashboard_link) # collect all flow run submissions submitted_runs list[dict] = [] for f_process in to_process <http //logger info|logger info>(f submitting processing for {f_process infile} ) # we run several child flows getting their results concurrently main_params = { import_file f_process infile working_dir working_dir overwrite overwrite } flow_run = await run_deployment( # type ignore[misc] name=f sen2-satproc-shared/s2_satproc_shared_{env_state} parameters=main_params timeout=0 job_variables={ env { DASK_SCHEDULER_ADDRESS scheduler_address } } ) <http //logger info|logger info>(f submitted flow run {flow_run name} - {flow_run id} for {f_process infile} ) submitted_runs append( { flow_run_id flow_run id import_file f_process infile } ) # wait for all concurrently and collect results as they finish files_saved list[str] = [] failures list[Exception] = [] pending = [asyncio create_task(resolve_flow_run(run_info)) for run_info in submitted_runs] for completed in asyncio as_completed(pending) r_d = await completed <http //logger info|logger info>( Child flow run %s finished for %s r_d[ flow_run_id ] r_d[ import_file ] ) result = r_d[ result ] if not isinstance(result Exception) <http //logger info|logger info>(f files saved {result=} ) files_saved extend(result) else <http //logger info|logger info>( %s - %s failed with error r_d[ import_file ] r_d[ flow_run_id ]) logger warning(result) failures append(result) <http //logger info|logger info>( Total number of files successfully saved = %s len(files_saved)) print(files_saved) logger warning( Number of child flow runs that failed = %s len(failures)) return if name == main # Check if parameters are passed as a command-line argument if len(sys argv) < 2 print( Usage python flows/s2_satproc_master_flow py '<parameters_as_json>' ) sys exit(1) # Parse the JSON string passed as the first argument parameters = json loads(sys argv[1]) # process_file = 'cgmsre_t55kfa_20260514_ab0 img' files_list = parameters get( files_list ) working_dir = parameters get( working_dir os getenv( SATPROC_SHARED_DIR )) if working
_dir working_dir = Path(working_dir) # check
s
@Marvin I am having problems using
<http://asyncio.as|asyncio.as>_completed
and initiating jobs on my
DaskTaskRunner
... This is my design, so far:
Copy code
async def wait_and_get_flow_run_result(flow_run_id: UUID) -> object:
    async with get_client() as client:
        flow_run = await wait_for_flow_run(
            flow_run_id,
            client=client,
            timeout=None,
            log_states=False,
        )

        return await flow_run.state.result(raise_on_failure=False)  # type: ignore[union-attr]


async def resolve_flow_run(run_info: dict) -> dict:
    result = await wait_and_get_flow_run_result(run_info["flow_run_id"])
    return {
        "flow_run_id": run_info["flow_run_id"],
        "import_file": run_info["import_file"],
        "result": result,
    }


# we run the master flow
@flow(
    log_prints=True,
    task_runner=DaskTaskRunner(  # type: ignore[arg-type]
        cluster_class=PBSCluster,
        cluster_kwargs=pbs_cluster_kwargs,
        adapt_kwargs={"maximum": MAX_WORKERS}
    )

)
async def s2_satproc_master(
    files_list: list[str],
    working_dir: Path,
    overwrite: bool = False,
    qvf_stages_out: set[str] = QVF_STAGES_OUT,
    env_state: str = 'dev'
) -> None:# and trigger the child (sub) flows
    """_summary_

    Args:
        files_list (list[str]): _description_
        working_dir (Path): _description_
        overwrite: _description_
        qvf_stages_out: _description_
        env_state (str, optional): _description_. Defaults to 'dev'.
    """
    logger = get_run_logger()

    # some prep logic goes here
    to_process: list[object] = []

    # get cluster and share address with child flows
    flow_ctx = FlowRunContext.get()
    if flow_ctx is None:
        raise RuntimeError("Could not access FlowRunContext in master flow.")

    task_runner = flow_ctx.task_runner
    scheduler_address = task_runner.client.scheduler.address  # type: ignore[attr-defined]
    dashboard_link = task_runner.client.dashboard_link  # type: ignore[attr-defined]

    <http://logger.info|logger.info>("Created shared Dask scheduler at %s", scheduler_address)
    <http://logger.info|logger.info>("Dask dashboard available at %s",   dashboard_link)

    # collect all flow run submissions
    submitted_runs: list[dict] = []
    for f_process in to_process:
        <http://logger.info|logger.info>(f"submitting processing for {f_process.infile}")
        # we run several child flows, getting their results concurrently
        main_params = {
            "import_file": f_process.infile,
            "working_dir": working_dir,
            "overwrite": overwrite,
        }

        flow_run = await run_deployment(  # type: ignore[misc]
            name=f"sen2-satproc-shared/s2_satproc_shared_{env_state}",
            parameters=main_params,
            timeout=0,
            job_variables={
                "env": {
                    "DASK_SCHEDULER_ADDRESS": scheduler_address,
                    }
                }
        )
        <http://logger.info|logger.info>(f"submitted flow run {flow_run.name} - {flow_run.id} for {f_process.infile}")

        submitted_runs.append(
            {
                "flow_run_id": flow_run.id,
                "import_file": f_process.infile
            }
        )

    # wait for all concurrently and collect results as they finish
    files_saved: list[str] = []
    failures: list[Exception] = []
    pending = [asyncio.create_task(resolve_flow_run(run_info)) for run_info in submitted_runs]
    for completed in asyncio.as_completed(pending):
        r_d = await completed
        <http://logger.info|logger.info>(
            "Child flow run %s finished for %s.",
            r_d["flow_run_id"], r_d["import_file"]
        )
        result = r_d["result"]
        if not isinstance(result, Exception):
            <http://logger.info|logger.info>(f"files saved: {result=}")
            files_saved.extend(result)
        else:
            <http://logger.info|logger.info>("%s - %s failed with error:", r_d["import_file"], r_d["flow_run_id"])
            logger.warning(result)
            failures.append(result)

    <http://logger.info|logger.info>("Total number of files successfully saved = %s", len(files_saved))
    print(files_saved)

    logger.warning("Number of child flow runs that failed = %s", len(failures))

    return

if __name__ == "__main__":
    asyncio.run(s2_satproc_master())
So, my deployment flow run are sent to the Prefect (process) worker, but no Dask cluster is initiated.
m
thought for 19.7 seconds
Thanks — this is a really useful symptom report. The key issue is likely not
asyncio.as_completed(...)
. The more important problem is this line at the bottom:
Copy code
python
if __name__ == "__main__":
    asyncio.run(s2_satproc_master())
For a Prefect flow, especially one declared as:
Copy code
python
@flow(
    task_runner=DaskTaskRunner(...)
)
async def s2_satproc_master(...):
you should not wrap it in your own
asyncio.run(...)
. ## Why this matters Prefect already manages the async execution of an async flow. Calling:
Copy code
python
asyncio.run(s2_satproc_master())
can interfere with Prefect’s runtime/engine behavior. Instead, just call the flow directly:
Copy code
python
if __name__ == "__main__":
    s2_satproc_master(...)
not:
Copy code
python
asyncio.run(s2_satproc_master(...))
--- # Most likely root cause of “no Dask cluster is initiated” Your master flow’s Dask cluster is created lazily. That means this:
Copy code
python
task_runner = flow_ctx.task_runner
scheduler_address = task_runner.client.scheduler.address
may or may not force initialization the way you expect, depending on when the client is actually created. But the bigger architectural issue is this: ##
run_deployment(...)
is orchestration work, not Dask task work Inside the master flow, you are mostly doing: - API calls - submission of child deployments - waiting on Prefect flow runs Those are not
@task
submissions to the master flow’s
DaskTaskRunner
. So if the master flow never actually submits Prefect tasks onto its task runner, the Dask cluster may not be meaningfully exercised the way you expect. That said, accessing:
Copy code
python
task_runner.client
should initialize the Dask client/cluster for
DaskTaskRunner
, so if no cluster at all appears, the
asyncio.run(...)
issue is my first suspect. --- # First fix Replace:
Copy code
python
if __name__ == "__main__":
    asyncio.run(s2_satproc_master())
with something like:
Copy code
python
if __name__ == "__main__":
    s2_satproc_master(
        files_list=[...],
        working_dir=Path("/some/path"),
    )
If you are running it via deployment, you do not need the
__main__
block at all. --- # Second fix: pass
scheduler_address
as a parameter too In your current
run_deployment(...)
call, you are only passing the address via `job_variables.env`:
Copy code
python
job_variables={
    "env": {
        "DASK_SCHEDULER_ADDRESS": scheduler_address,
    }
}
But your earlier child design had a flow parameter-based pattern. If your child shared entrypoint expects
scheduler_address
as a flow parameter, then you should pass it there explicitly. Example:
Copy code
python
main_params = {
    "import_file": f_process.infile,
    "working_dir": str(working_dir),
    "overwrite": overwrite,
    "scheduler_address": scheduler_address,
}
Then:
Copy code
python
flow_run = await run_deployment(
    name=f"sen2-satproc-shared/s2_satproc_shared_{env_state}",
    parameters=main_params,
    timeout=0,
)
That is cleaner and avoids ambiguity. --- # Third fix: ensure your child flow actually attaches the Dask runner before task submission This is still the crucial part. If the child deployment entrypoint is not doing the
with_options(task_runner=DaskTaskRunner(address=scheduler_address))
pattern correctly, then the child can still wind up running locally. So I would verify the child deployed entrypoint really looks like this shape:
Copy code
python
@flow
def child_flow_shared(
    import_file: str,
    scheduler_address: str,
    working_dir: Path | None = None,
    overwrite: bool = False,
):
    configured_flow = child_flow_logic.with_options(
        task_runner=DaskTaskRunner(address=scheduler_address)
    )
    return configured_flow(
        import_file=import_file,
        working_dir=working_dir,
        overwrite=overwrite,
    )
If instead the child is doing
with runner:
inside the flow body, that is still broken for Prefect task submission. ---
# Fourth fix: force master task runner warmup before reading address Even though
task_runner.client
may initialize the cluster, I would make this explicit. Add a tiny task and submit it first. ## Example
Copy code
python
from prefect import task

@task
def warmup() -> str:
    return "ok"
Then in the master flow:
Copy code
python
warmup.submit().result()
before:
Copy code
python
task_runner = flow_ctx.task_runner
scheduler_address = task_runner.client.scheduler.address
That ensures the Dask task runner is actually started and used. --- # Recommended revised master pattern ```python import asyncio from pathlib import Path from uuid import UUID from prefect import flow, get_run_logger, task from prefect.context import FlowRunContext from prefect.client.orchestration import get_client from prefect.deployments.flow_runs import run_deployment from prefect.flow_runs import wait_for_flow_run from prefect_dask import DaskTaskRunner from dask_jobqueue import PBSCluster MAX_WORKERS = 20 pbs_cluster_kwargs: dict = { # your PBS config } @task def warmup() -> str: return "ok" async def wait_and_get_flow_run_result(flow_run_id: UUID) -> object: async with get_client() as client: flow_run = await wait_for_flow_run( flow_run_id, client=client, timeout=None, log_states=False, ) return await flow_run.state.result(raise_on_failure=False) async def resolve_flow_run(run_info: dict) -> dict: result = await wait_and_get_flow_run_result(run_info["flow_run_id"]) return { "flow_run_id": run_info["flow_run_id"], "import_file": run_info["import_file"], "result": result, } @flow( log_prints=True, task_runner=DaskTaskRunner( cluster_class=PBSCluster, cluster_kwargs=pbs_cluster_kwargs, adapt_kwargs={"maximum": MAX_WORKERS}, ), ) async def s2_satproc_master( files_list: list[str], working_dir: Path, overwrite: bool = False, env_state: str = "dev", ) -> list[str]: logger = get_run_logger() to_process = files_list # Force Dask cluster startup warmup.submit().result() flow_ctx = FlowRunContext.get() if flow_ctx is None: raise RuntimeError("Could not access FlowRunContext in master flow.") task_runner = flow_ctx.task_runner scheduler_address = task_runner.client.scheduler.address dashboard_link = task_runner.client.dashboard_link logger.info("Created shared Dask scheduler at %s", scheduler_address) logger.info("Dask dashboard available at %s", dashboard_link) submitted_runs: list[dict] = [] for infile in to_process: logger.info("Submitting processing for %s", infile) flow_run = await run_deployment( name=f"sen2-satproc-shared/s2_satproc_shared_{env_state}", parameters={ "import_file": infile, "working_dir": str(working_dir), "overwrite": overwrite, "scheduler_address": scheduler_address, }, timeout=0, poll_interval=5, ) logger.info( "Submitted flow run %s - %s for %s", flow_run.name, flow_run.id, infile, ) submitted_runs.append( { "flow_run_id": flow_run.id, "import_file": infile, } ) files_saved: list[str] = [] failures: list[Exception] = [] pending = [asyncio.create_task(resolve_flow_run(run_info)) for run_info in submitted_runs] for completed in asyncio.as_completed(pending): r_d = await completed logger.info( "Child flow run %s finished for %s.", r_d["flow_run_id"], r_d["import_file"], ) result = r_d["result"] if not isinstance(result, Exception): logger.info("files saved result=%s", result)
files_saved.extend(result) else: logger.warning( "%s - %s failed with error: %s", r_d["import_file"], r_d["flow_run_id"], result, ) failures.append(result) logger.info("Total number of files successfully saved = %s", len(files_saved)) logger.warning("Number of child flow runs that failed = %s", len(failures)) return files_saved ``` --- # Most important takeaways ##
asyncio.as_completed(...)
is probably not the cause That part is fine for waiting on many child flow results concurrently. ## The likely issues are: 1. using
asyncio.run(...)
around a Prefect flow 2. not forcing the Dask runner to actually initialize 3. child deployment not attaching
DaskTaskRunner(address=...)
correctly before task submission 4. passing the address via env when your design is already parameter-based --- # What I would test next ## Test 1 Remove
asyncio.run(...)
and call the flow normally. ## Test 2 Add
warmup.submit().result()
before reading
task_runner.client.scheduler.address
. ## Test 3 Pass
scheduler_address
directly in
parameters
, not only via
job_variables
. ## Test 4 Confirm the child entrypoint uses
with_options(task_runner=DaskTaskRunner(address=scheduler_address))
. --- If you want, I can now do a full consistency pass and rewrite both: - your current
master
flow - and the
child shared
entrypoint so they exactly match each other and avoid the parameter/env mismatch.
s
@Marvin I can't run/test my
master.py
flow code locally without
asyncio.run(s2_satproc_master())
, I get:
Copy code
master_flow.py:354: RuntimeWarning: coroutine 'run_flow_async' was never awaited
This is my new child deployment code:
Copy code
SHARED_SCHEDULER_ADDRESS = os.getenv("DASK_SCHEDULER_ADDRESS")
@flow(
    task_runner=DaskTaskRunner(address=SHARED_SCHEDULER_ADDRESS),  # type: ignore[arg-type]
    log_prints=False,
    persist_result=True,
    retries=1,
    retry_delay_seconds=20,
)
def sen2_satproc_shared(
    import_file: str,
    working_dir: Optional[Path] = None,
    overwrite: bool = False,
) -> list[str]:
    """_summary_

    Args:
        import_file (str): _description_
        working_dir (Optional[Path], optional): _description_. Defaults to None.
        overwrite (bool, optional): _description_. Defaults to False.

    Raises:
        ValueError: _description_

    Returns:
        list[str]: _description_
    """
    logger = get_run_logger()

    if not SHARED_SCHEDULER_ADDRESS or not SHARED_SCHEDULER_ADDRESS.strip():
        raise ValueError("scheduler_address must be a non-empty string.")

    <http://logger.info|logger.info>(
        "Preparing shared child flow using external Dask scheduler at %s",
        SHARED_SCHEDULER_ADDRESS,
    )

    # configured_flow = sen2_satproc.with_options(
    #     task_runner=DaskTaskRunner(address=scheduler_address),  # type: ignore[arg-type]
    # )

    return child_flow(  # now a plain python function with the business logic
        import_file=import_file,
        working_dir=working_dir,
        overwrite=overwrite
    )
m
thought for 20.1 seconds
Yes — for an async Prefect flow, calling it directly from a plain Python script can require
asyncio.run(...)
. So let me correct the earlier guidance: - If
s2_satproc_master
is
async def
, then in a plain local script, this is normal:
Copy code
python
if __name__ == "__main__":
    asyncio.run(
        s2_satproc_master(
            files_list=[...],
            working_dir=Path("/tmp"),
        )
    )
The warning you saw:
Copy code
python
RuntimeWarning: coroutine 'run_flow_async' was never awaited
means exactly that: you invoked an async flow without awaiting the coroutine it returned. So for local script execution,
asyncio.run(...)
is fine. --- # On your new child deployment code This is much more interesting:
Copy code
python
SHARED_SCHEDULER_ADDRESS = os.getenv("DASK_SCHEDULER_ADDRESS")

@flow(
    task_runner=DaskTaskRunner(address=SHARED_SCHEDULER_ADDRESS),
    ...
)
def sen2_satproc_shared(...):
    ...
    return child_flow(...)
This can work if and only if the environment variable is already set when the module is imported. That is the critical detail. ## Why This line:
Copy code
python
SHARED_SCHEDULER_ADDRESS = os.getenv("DASK_SCHEDULER_ADDRESS")
runs at module import time, not at flow run time. And this decorator:
Copy code
python
@flow(
    task_runner=DaskTaskRunner(address=SHARED_SCHEDULER_ADDRESS),
)
also gets evaluated at import time. So the task runner address is frozen from whatever the env var was when Python imported the file. --- # Why this can fail in your deployment setup If the process worker imports the module before the runtime env var is set the way you expect, then:
Copy code
python
SHARED_SCHEDULER_ADDRESS
may be
None
or stale at decoration time. Then the flow is effectively defined with:
Copy code
python
DaskTaskRunner(address=None)
which is not what you want. So this pattern is only safe if: - the worker process environment definitely contains
DASK_SCHEDULER_ADDRESS
- before the child flow module is imported That’s why I had raised the env timing concern earlier. --- # Another key detail in your current child flow You now have:
Copy code
python
@flow(
    task_runner=DaskTaskRunner(address=SHARED_SCHEDULER_ADDRESS),
)
def sen2_satproc_shared(...):
    ...
    return child_flow(...)
and you said:
Copy code
python
return child_flow(  # now a plain python function with the business logic
That part is actually a good simplification. If
child_flow
is now just a plain Python function containing business logic, and the actual Prefect tasks inside that logic are submitted from within
sen2_satproc_shared
, then this can be okay. But if
child_flow
itself is another Prefect flow, then this becomes a different orchestration pattern. So the distinction matters: ## Good
Copy code
python
def child_flow(...):
    # plain python business logic
    futures = [my_task.submit(...)]
## Different pattern
Copy code
python
@flow
def child_flow(...):
    ...
From your note, it sounds like you changed it to plain Python logic, which is the better direction here. --- # The main issue I still see Using:
Copy code
python
SHARED_SCHEDULER_ADDRESS = os.getenv("DASK_SCHEDULER_ADDRESS")
at module scope is fragile. ## Better pattern If you want env-based configuration, build the configured flow after reading the env at runtime, not at import time. Because decorators are static, the cleanest way is a wrapper. --- # Safer child shared pattern ```python import os from pathlib import Path from typing import Optional from prefect import flow, get_run_logger from prefect_dask import DaskTaskRunner def child_flow_logic( import_file: str, working_dir: Optional[Path] = None, overwrite: bool = False, ) -> list[str]: # plain Python business logic that calls Prefect tasks ... return [] @flow(log_prints=False, persist_result=True, retries=1, retry_delay_seconds=20) def sen2_satproc_shared_entrypoint( import_file: str, working_dir: Optional[Path] = None,
overwrite: bool = False, ) -> list[str]: logger = get_run_logger() scheduler_address = os.getenv("DASK_SCHEDULER_ADDRESS") if not scheduler_address or not scheduler_address.strip(): raise ValueError("DASK_SCHEDULER_ADDRESS must be set and non-empty.") logger.info( "Preparing shared child flow using external Dask scheduler at %s", scheduler_address, ) configured = _sen2_satproc_shared_logic.with_options( task_runner=DaskTaskRunner(address=scheduler_address) ) return configured( import_file=import_file, working_dir=working_dir, overwrite=overwrite, ) @flow(log_prints=False, persist_result=True, retries=1, retry_delay_seconds=20) def _sen2_satproc_shared_logic( import_file: str, working_dir: Optional[Path] = None, overwrite: bool = False, ) -> list[str]: return child_flow_logic( import_file=import_file, working_dir=working_dir, overwrite=overwrite, )
Copy code
This avoids freezing the scheduler address at import time.

---

# But since you asked about your current exact approach

## Is this valid?

```python
SHARED_SCHEDULER_ADDRESS = os.getenv("DASK_SCHEDULER_ADDRESS")

@flow(task_runner=DaskTaskRunner(address=SHARED_SCHEDULER_ADDRESS))
def sen2_satproc_shared(...):
### Answer: It can work, but it is only safe if that env var is guaranteed to be present and correct before the module is imported. That’s the weakness. --- # If you prefer parameter passing instead of env vars Given everything you’ve said, I still think parameter passing is cleaner for the master → child handoff. But because the task runner must be attached before task submission starts, parameter-driven configuration still usually wants the wrapper pattern. That means: ## Wrapper flow gets the parameter
Copy code
python
@flow
def sen2_satproc_shared_entrypoint(import_file: str, scheduler_address: str, ...):
## Then it constructs the real configured flow
Copy code
python
configured = shared_logic_flow.with_options(
    task_runner=DaskTaskRunner(address=scheduler_address)
)
return configured(...)
That is more reliable than env-at-import-time. --- # My recommendation now Given your current progress, I’d suggest: ## If you want the least intrusive next test Keep your current code, but change this:
Copy code
python
SHARED_SCHEDULER_ADDRESS = os.getenv("DASK_SCHEDULER_ADDRESS")
to be read inside the flow body only for validation/logging — and move actual runner attachment to a wrapper/configured flow pattern. ## If you want the most robust design Use a shared entrypoint wrapper that: - receives
scheduler_address
as a parameter - creates a configured flow with
with_options(task_runner=DaskTaskRunner(address=scheduler_address))
- invokes the configured flow That avoids the import-time env trap. --- # Short answers ## Why do you need
asyncio.run(...)
locally? Because your master flow is
async def
, and calling it from plain Python returns a coroutine that must be awaited. ## Is your new child deployment code okay? Potentially, but it is fragile because the scheduler address is captured at import time. ## Is plain Python business logic inside the flow a good move? Yes, that part is good. --- If you want, I can now give you the final corrected shared child deployment recipe in one of these two forms: 1. env-based but runtime-safe 2. parameter-based wrapper flow — which I think is the better one for your master/child design
s
@Marvin I think that this run deployment approach is working as expected:
Copy code
flow_run = await run_deployment(  # type: ignore[misc]
            name=f"sen2-satproc-shared/s2_satproc_shared_{env_state}",
            parameters=main_params,
            timeout=0,
            job_variables={
                "env": {
                    "DASK_SCHEDULER_ADDRESS": scheduler_address,
                    }
                }
        )
In my child deployment flow run logs I am seeing the matching scheduler address from my static
DaskTaskRunner
in master.py:
Copy code
Beginning subflow run 'smoky-oyster' for flow 'sen2-satproc-shared'

Preparing shared child flow using external Dask scheduler at <tcp://xx.xxx.x.xx>:XXXXX  # this matches the address from my master.py
m
thought for 15.4 seconds
That’s a very good sign — it means the handoff mechanism itself is likely working. If the child flow logs show:
Copy code
python
Preparing shared child flow using external Dask scheduler at tcp://...
and that address matches the one from the master flow, then at least these parts are working: - the master is creating a Dask scheduler -
run_deployment(...)
is launching child runs correctly -
job_variables.env.DASK_SCHEDULER_ADDRESS
is reaching the child run environment - the child flow code is reading the expected address So yes — your
run_deployment(...)
approach for passing the scheduler address via
job_variables
appears to be working as intended. --- # What this confirms It confirms transport of the scheduler address, but not yet necessarily successful execution of Prefect tasks on that cluster. So you’ve likely moved past the “parameter/env handoff” problem. The next thing to validate is: ## Is the child flow’s actual task submission using that Dask runner? Because there are still two separate questions: 1. Did the child receive the right scheduler address? - seems to be yes 2. Are the child’s Prefect tasks actually executing on that external Dask cluster rather than locally? - this still needs confirmation --- # What I would test next Inside the child flow, add a small diagnostic task that tells you where it is running. For example:
Copy code
python
import socket
from prefect import task
from prefect_dask import get_dask_client

@task
def debug_dask_execution() -> str:
    host = socket.gethostname()

    with get_dask_client() as client:
        scheduler = client.scheduler.address
        workers = list(client.scheduler_info()["workers"].keys())

    return f"host={host}, scheduler={scheduler}, workers={workers}"
Then in the child flow:
Copy code
python
info = debug_dask_execution.submit().result()
<http://logger.info|logger.info>("Dask execution diagnostic: %s", info)
This helps answer: - is the task actually running in Dask task context? - what scheduler is it connected to? - what workers does Dask think are available? --- # A very important nuance Even if the child flow is connected to the correct scheduler, if the body of the child flow mostly does plain Python work directly in the flow function, that work will still execute in the flow-run process itself. Only Prefect tasks submitted with
.submit()
go through the task runner. So if your child flow looks like:
Copy code
python
@flow(task_runner=DaskTaskRunner(...))
def sen2_satproc_shared(...):
    result = expensive_python_function(...)
    return result
then
expensive_python_function(...)
runs locally in the child flow process, not on Dask workers. To use the Dask cluster, the expensive parts must be inside `@task`s and submitted:
Copy code
python
@task
def expensive_python_function(...):
    ...

@flow(task_runner=DaskTaskRunner(...))
def sen2_satproc_shared(...):
    future = expensive_python_function.submit(...)
    return future.result()
This is one of the most common causes of “the Dask cluster exists but work seems to happen locally”. --- # So the likely remaining issue is one of these ## Case 1: Address handoff works, but work is not submitted as Prefect tasks Then processing stays local. ## Case 2: Address handoff works, tasks are submitted, but workers are not actually available/reachable Then the scheduler exists, but execution may stall or fall back in a way that looks local. ## Case 3: Flow body is doing too much work outside tasks Then Dask appears unused even though the cluster connection is correct. --- # What your latest observation means operationally Your earlier concern:
maybe env passing to child won’t work
looks much less likely now. So I’d update the guidance to: Your env-based
run_deployment(..., job_variables={"env": ...})
approach appears valid in your setup.
That means the remaining debugging should focus on: - task submission structure - Dask worker availability
- where the actual heavy computation lives --- # Recommended next diagnostics ## 1. Confirm child tasks are submitted with
.submit()
The heavy compute should be in tasks, not inline in the flow function. ## 2. Add a diagnostic task Example:
Copy code
python
import os
import socket
from prefect import task
from prefect_dask import get_dask_client

@task
def debug_cluster() -> dict:
    with get_dask_client() as client:
        info = client.scheduler_info()

    return {
        "hostname": socket.gethostname(),
        "pid": os.getpid(),
        "scheduler": info["address"],
        "workers": list(info["workers"].keys()),
    }
## 3. Log worker count before real work In the child flow:
Copy code
python
diag = debug_cluster.submit().result()
<http://logger.info|logger.info>("Cluster diagnostics: %s", diag)
## 4. Make sure the heavy work is inside tasks If you want, I’d strongly review the actual business-logic section for this. --- # Short answer Yes — from what you’ve shown, the
run_deployment(..., job_variables={"env": ...})
approach does appear to be working correctly for passing the scheduler address to the child deployment. So the next problem is probably not scheduler-address propagation anymore. It’s more likely about whether: - the child’s task runner is actually being used for submitted tasks, and/or - the expensive work is really inside submitted Prefect tasks. --- If you want, send me the child flow body plus one representative heavy task, and I can help pinpoint whether the work is actually structured to execute on Dask workers.
s
@Marvin Added
debug_cluster()
to my child shared deployment flow:
Copy code
@task
def debug_cluster() -> dict:
    with get_dask_client() as client:
        info = client.scheduler_info()

    return {
        "hostname": socket.gethostname(),
        "pid": os.getpid(),
        "scheduler": info["address"],
        "workers": list(info["workers"].keys()),
    }


SHARED_SCHEDULER_ADDRESS = os.getenv("DASK_SCHEDULER_ADDRESS")
@flow(
    task_runner=DaskTaskRunner(address=SHARED_SCHEDULER_ADDRESS),  # type: ignore[arg-type]
    log_prints=False,
    persist_result=True,
    retries=1,
    retry_delay_seconds=20,
)
def sen2_satproc_shared(
    import_file: str,
    working_dir: Optional[Path] = None,
    overwrite: bool = False,
) -> list[str]:
    """_summary_

    Args:
        import_file (str): _description_
        working_dir (Optional[Path], optional): _description_. Defaults to None.
        overwrite (bool, optional): _description_. Defaults to False.

    Raises:
        ValueError: _description_

    Returns:
        list[str]: _description_
    """
    logger = get_run_logger()

    if not SHARED_SCHEDULER_ADDRESS or not SHARED_SCHEDULER_ADDRESS.strip():
        raise ValueError("scheduler_address must be a non-empty string.")

    <http://logger.info|logger.info>(
        "Preparing shared child flow using external Dask scheduler at %s",
        SHARED_SCHEDULER_ADDRESS,
    )

    # configured_flow = sen2_satproc.with_options(
    #     task_runner=DaskTaskRunner(address=scheduler_address),  # type: ignore[arg-type]
    # )

    diag = debug_cluster.submit().result()
    <http://logger.info|logger.info>("Cluster diagnostics: %s", diag)

    return child_flow(
        import_file=import_file,
        working_dir=working_dir,
        overwrite=overwrite
    )
Processing gets stuck at:
Copy code
diag = debug_cluster.submit().result()
Nothing happens, no cluster is added to my PBS scheduler and logs stop just before this point:
Copy code
Preparing shared child flow using external Dask scheduler at <tcp://xx.xxx.x.xx>:XXXXX
Also, note that this problem arose when I implemented waiting for my submitted deployments concurrently, as I am doing now. This used to work well with a shared cluster between
master.py
and my child deployment:
Copy code
@flow(
    log_prints=True,
    task_runner=DaskTaskRunner(  # type: ignore[arg-type]
        cluster_class=PBSCluster,
        cluster_kwargs=pbs_cluster_kwargs,
        adapt_kwargs={"maximum": MAX_WORKERS}
    )

)
async def s2_satproc_master(
    files_list: list[str],
    working_dir: Path,
    overwrite: bool = False,
    qvf_stages_out: set[str] = QVF_STAGES_OUT,
    env_state: str = 'dev'
) -> None:# and trigger the child (sub) flows
# passing

    """_summary_

    Args:
        files_list (list[str]): _description_
        working_dir (Path): _description_
        overwrite: _description_
        qvf_stages_out: _description_
        env_state (str, optional): _description_. Defaults to 'dev'.
    """
    logger = get_run_logger()

    # ...

    # get cluster and share address with child flows
    flow_ctx = FlowRunContext.get()
    if flow_ctx is None:
        raise RuntimeError("Could not access FlowRunContext in master flow.")

    task_runner = flow_ctx.task_runner
    scheduler_address = task_runner.client.scheduler.address  # type: ignore[attr-defined]
    dashboard_link = task_runner.client.dashboard_link  # type: ignore[attr-defined]

    <http://logger.info|logger.info>("Created shared Dask scheduler at %s", scheduler_address)
    <http://logger.info|logger.info>("Dask dashboard available at %s",   dashboard_link)

    # collect all flow run submissions
    submitted_runs: list[dict] = []
    for f_process in to_process:
        <http://logger.info|logger.info>(f"submitting processing for {f_process.infile}")
        # we run several child flows, getting their results concurrently
        main_params = {
            "import_file": f_process.infile,
            "working_dir": working_dir,
            "overwrite": overwrite,
        }

        flow_run = await run_deployment(  # type: ignore[misc]
            name=f"sen2-satproc-shared/s2_satproc_shared_{env_state}",
            parameters=main_params,
            timeout=0,
            job_variables={
                "env": {
                    "DASK_SCHEDULER_ADDRESS": scheduler_address,
                    }
                }
        )
        
        files_saved: object = await get_flow_run_result(flow_run.id)
m
thought for 22.0 seconds
What you’re seeing strongly suggests a deadlock / scheduling starvation pattern, not a problem with
debug_cluster()
itself. The key difference between the version that used to work and the one that now hangs is: - before: you submitted one child deployment, then waited for it to finish - now: you submit many child deployments and wait for them concurrently That changes the lifetime and occupancy of your shared Dask cluster. --- # Most likely explanation Your child flow is using the same shared Dask scheduler as the master. Inside the child flow, this line:
Copy code
python
diag = debug_cluster.submit().result()
submits a Prefect task to Dask and then blocks waiting for the result. If multiple child deployments all connect to the same cluster concurrently, it’s possible you are now saturating or deadlocking the cluster with: - flow-run processes waiting on tasks - no worker capacity available to execute those tasks - or PBS workers not being scaled/launched in time for the connected clients In other words: the scheduler connection is there, but actual worker execution is not progressing. --- # Why this showed up only after concurrent waiting Your old pattern:
Copy code
python
flow_run = await run_deployment(...)
files_saved = await get_flow_run_result(flow_run.id)
is effectively serial orchestration. That means: - one child flow at a time - one child consuming the shared cluster at a time - much less cluster contention Your new pattern: - launches many child deployments quickly - all of them connect to the same shared Dask scheduler - each child immediately tries to submit Dask-backed tasks - they may all be competing for a cluster that has not yet scaled out or cannot scale fast enough So the change in behavior is very plausibly from concurrency on the shared cluster, not from
asyncio.as_completed(...)
directly. --- # Important clue from your logs You said: - the child logs reach:
Copy code
python
  Preparing shared child flow using external Dask scheduler at tcp://...
- then hang on:
Copy code
python
  diag = debug_cluster.submit().result()
- and no new PBS workers are added That means: - the child process starts fine - the child can read the scheduler address fine - the flow task runner is probably trying to submit to Dask - but the scheduler is not producing runnable worker capacity for that submitted task This is exactly the sort of thing you’d see if: - there are no available Dask workers - or workers are all occupied / blocked - or adaptive scaling isn’t responding usefully under this cross-deployment pattern --- # The first thing I would test ## Reduce back to one child run with the new child code Keep the new
debug_cluster.submit().result()
in place, but launch only one child deployment. If one child works, but many concurrent children hang, then the issue is almost certainly cluster contention / scaling / worker starvation. That is the most important diagnostic split. --- # Second thing I would test ## Avoid using the shared Dask cluster for the child’s very first diagnostic step Right now your diagnostic itself depends on successful Dask task execution:
Copy code
python
diag = debug_cluster.submit().result()
But that means you can’t distinguish: - “the child cannot connect to scheduler” from - “the child can connect, but no worker is available to execute the task” Instead, try a flow-level diagnostic first using
get_dask_client()
directly in the flow body if possible. Example:
Copy code
python
from prefect_dask import get_dask_client

@flow(...)
def sen2_satproc_shared(...):
    logger = get_run_logger()

    with get_dask_client() as client:
        info = client.scheduler_info()
        <http://logger.info|logger.info>("Scheduler info in flow context: %s", {
            "address": info["address"],
            "workers": list(info["workers"].keys()),
        })

    diag = debug_cluster.submit().result()
This can tell you whether the flow-run process itself has a usable Dask client connection before requiring a worker task to run. If this logs scheduler info but
debug_cluster.submit().result()
still hangs, then the issue is specifically worker execution availability. --- # Third thing I would test ## Inspect worker count before task submission In the child flow:
Copy code
python
with get_dask_client() as client:
    info = client.scheduler_info()
    <http://logger.info|logger.info>("Connected workers: %s", list(info["workers"].keys()))
If that prints zero workers, the problem becomes much clearer. --- # Fourth thing I would test ## Force the master cluster to have real workers before launching children Right now the master uses adaptive scaling:
Copy code
python
adapt_kwargs={"maximum": MAX_WORKERS}
That means the shared cluster may start with effectively zero workers. That can be okay for one workload, but for multiple external child deployments all connecting concurrently, it may be too fragile. For debugging, I would temporarily disable adaptive-only startup and force a fixed number of workers. If you are using the master’s
DaskTaskRunner(cluster_class=PBSCluster, ...)
, you may not have direct
cluster.scale(...)
access from the flow decorator pattern. So for a meaningful test, switch the master to explicit cluster management temporarily:
Copy code
python
with PBSCluster(...) as cluster:
    cluster.scale(2)
    with Client(cluster) as client:
        scheduler_address = client.scheduler.address
        ...
Then launch one child, then several children. If that works much better, the problem is adaptive scaling / worker cold-start timing, not address sharing. --- # Fifth thing I would test ## Add per-child concurrency limits at the orchestration level If many child deployments are all sharing one cluster, you may simply be oversubscribing it. Instead of launching all children at once, try batching. For example: - launch 2 or 3 child deployments at a time - wait for those - then launch the next batch That is often much more stable on HPC/PBS-backed Dask than “fire off everything at once”. --- # My current hypothesis ranking ## Most likely 1. Shared cluster has insufficient worker capacity when many child deployments connect concurrently 2. Adaptive scaling from the master-owned cluster is too slow / too cold for this pattern 3. Children are all blocking on Dask tasks with no workers available ## Less likely 4.
asyncio.as_completed(...)
itself is broken 5. scheduler address propagation is broken Your logs make #4 and #5 much less likely. --- # Why
debug_cluster.submit().result()
is a very sensitive trigger That line is effectively a canary. It’s the first true Dask-routed Prefect task in the child. So if it hangs, that means: - the child flow startup/logging path is okay - the Dask-backed task execution path is where the system stops progressing That’s actually a very useful result. --- # Concrete next-step recipe ## Step 1: Single-child test Run the new child version with only one child deployment from the master. If that works: - your child flow design is basically okay - the issue is concurrent shared-cluster usage ## Step 2: Flow-level Dask client diagnostic Add:
Copy code
python
with get_dask_client() as client:
    info = client.scheduler_info()
    <http://logger.info|logger.info>(
        "Scheduler connected in flow context: address=%s workers=%s",
        info["address"],
        list(info["workers"].keys()),
    )
before
debug_cluster.submit().result()
. ## Step 3: Try fixed worker count in master cluster For debugging only, replace adaptive-only behavior with a fixed scale. ## Step 4: Batch child deployments Instead of all-at-once, try small batches. --- # My practical recommendation At this point, I would not continue debugging
as_completed(...)
itself. I would treat this as a shared Dask cluster capacity / scheduling behavior issue caused by concurrent child deployments. ---
# One more important design observation A shared temporary cluster across many separate deployments is a much harder pattern than: - one flow with Dask tasks - or one child deployment at a time You are effectively building a multi-client shared scheduler scenario on top of an adaptive HPC cluster. That can work, but it’s much more operationally sensitive. For HPC/PBS, I would seriously consider: - serial or batched child deployments, or - one standalone child cluster per child deployment, if isolation is acceptable, or - one larger explicit shared cluster with fixed initial workers, rather than a cold adaptive cluster --- If you want, I can help you with either of these next: 1. a batched master-flow implementation using
asyncio.gather
in chunks, or 2. a debug version of the child flow that distinguishes: - scheduler connectivity - worker availability - actual task execution failure
s
@Marvin Using:
Copy code
python
with get_dask_client() as client:
    info = client.scheduler_info()
    <http://logger.info|logger.info>(
        "Scheduler connected in flow context: address=%s workers=%s",
        info["address"],
        list(info["workers"].keys()),
    )
And getting the error:
Copy code
AttributeError: 'NoneType' object has no attribute 'scheduler_info'
m
thought for 15.1 seconds
That error is very informative. It means
get_dask_client()
is not returning a usable Dask client in that point of execution, so this:
Copy code
python
with get_dask_client() as client:
is effectively yielding
None
, and then:
Copy code
python
client.scheduler_info()
fails. ## What that strongly suggests In your child flow, despite:
Copy code
python
@flow(
    task_runner=DaskTaskRunner(address=SHARED_SCHEDULER_ADDRESS),
    ...
)
the active flow execution context does not have a live Dask client available there. That points back to the import-time/static-env pattern being fragile. --- # Why this matters Your current child flow is defined like this:
Copy code
python
SHARED_SCHEDULER_ADDRESS = os.getenv("DASK_SCHEDULER_ADDRESS")

@flow(
    task_runner=DaskTaskRunner(address=SHARED_SCHEDULER_ADDRESS),
)
def sen2_satproc_shared(...):
That means the
DaskTaskRunner
is configured once, at import time, using whatever value
os.getenv(...)
had then. Even though your log later shows the env var is present in the flow body, that does not prove the decorated task runner was constructed with the right live address at the right moment. So you may have: - the env var available when the flow function runs - but the flow’s
task_runner
object was built earlier in a bad/empty state That would explain why: - logging the env var works - but there is no real usable Dask client in context --- # Bottom line This is the strongest evidence yet that you should stop using module-level env capture for the child shared flow’s task runner. In other words, this pattern is the problem:
Copy code
python
SHARED_SCHEDULER_ADDRESS = os.getenv("DASK_SCHEDULER_ADDRESS")
@flow(task_runner=DaskTaskRunner(address=SHARED_SCHEDULER_ADDRESS))
--- # Recommended fix Switch to the wrapper flow +
with_options(...)
pattern for the child shared deployment. That way: - the wrapper flow starts normally - reads the env var at runtime - creates a configured flow with
DaskTaskRunner(address=...)
- then calls that configured flow This avoids freezing a possibly invalid address at import time. --- # Recommended child shared pattern ##
child_shared_logic.py
Copy code
python
from pathlib import Path
from typing import Optional

from prefect import task, get_run_logger

@task
def debug_cluster() -> dict:
    import os
    import socket
    from prefect_dask import get_dask_client

    with get_dask_client() as client:
        info = client.scheduler_info()

    return {
        "hostname": socket.gethostname(),
        "pid": os.getpid(),
        "scheduler": info["address"],
        "workers": list(info["workers"].keys()),
    }


def child_flow_logic(
    import_file: str,
    working_dir: Optional[Path] = None,
    overwrite: bool = False,
) -> list[str]:
    logger = get_run_logger()

    diag = debug_cluster.submit().result()
    <http://logger.info|logger.info>("Cluster diagnostics: %s", diag)

    # your real logic here
    return []
##
child_entrypoints.py
```python import os from pathlib import Path from typing import Optional from prefect import flow, get_run_logger from prefect_dask import DaskTaskRunner from child_shared_logic import child_flow_logic @flow( name="sen2-satproc-shared-entrypoint", log_prints=False, persist_result=True, retries=1, retry_delay_seconds=20, ) def sen2_satproc_shared( import_file: str, working_dir: Optional[Path] = None, overwrite: bool = False, ) -> list[str]: logger = get_run_logger() scheduler_address = os.getenv("DASK_SCHEDULER_ADDRESS") if not scheduler_address or not scheduler_address.strip(): raise ValueError("DASK_SCHEDULER_ADDRESS must be a non-empty string.") logger.info( "Preparing shared child flow using external Dask scheduler at %s", scheduler_address, ) configured_flow = _sen2_satproc_shared_logic.with_options( task_runner=DaskTaskRunner(address=scheduler_address) ) return configured_flow(
import_file=import_file, working_dir=working_dir, overwrite=overwrite, ) @flow( name="sen2-satproc-shared-logic", log_prints=False, persist_result=True, retries=1, retry_delay_seconds=20, ) def _sen2_satproc_shared_logic( import_file: str, working_dir: Optional[Path] = None, overwrite: bool = False, ) -> list[str]: return child_flow_logic( import_file=import_file, working_dir=working_dir, overwrite=overwrite, )
Copy code
---

# Why this is better

Because now the Dask runner is created from:

```python
scheduler_address = os.getenv("DASK_SCHEDULER_ADDRESS")
inside the wrapper flow at runtime. Then:
Copy code
python
_sen2_satproc_shared_logic.with_options(
    task_runner=DaskTaskRunner(address=scheduler_address)
)
creates the actual task-submitting flow with the correct address before that flow starts. That is much safer than import-time env capture. --- # Also: why
get_dask_client()
returned
None
This usually means one of: - no active Dask task runner client is available in current flow/task context - the runner didn’t initialize correctly - the flow isn’t actually running with the Dask task runner you think it is Given your current pattern, #3 is the most plausible. --- # Short answer Your
AttributeError: 'NoneType' object has no attribute 'scheduler_info'
is strong evidence that the child flow is not actually running with a live Dask client bound in context, despite the log message showing the expected scheduler address string. That’s why I would switch now to the runtime wrapper pattern instead of the import-time env/decorator pattern. --- If you want, I can now rewrite: - your current child shared deployment - and the matching
prefect.yaml
to use this wrapper-based runtime-safe design exactly.
s
@Marvin Okay, I was able to isolate the issue by submitting one of my (non-parellel) Prefect tasks -similar to your warmup task suggestin- to the de static cluster in my
master.py
and I can confirm that nothing happens: a dask cluster seems to be created but no workers assigned to the cluster:
Copy code
14:53:49.890 | INFO    | prefect.task_runner.dask - Creating a new Dask cluster with `dask_jobqueue.pbs.PBSCluster
I think the issue lies that I am mixing async functions (tasks) with plain tasks in my flow definition. So, the question can one have
async def
and plain functions and still run a flow concurrently?
m
thought for 110.5 seconds
Yes — you can mix
async def
and plain
def
in Prefect, including with
DaskTaskRunner
. So the short answer is: No, mixing async and sync tasks is probably not the reason your PBS workers are not starting / not doing work. ## What is supported In Prefect 3.x: - sync flow + sync tasks → supported - sync flow + async tasks → supported - async flow + sync tasks → supported - async flow + async tasks → supported - mixed sync/async tasks in the same flow → supported With
prefect-dask
, async tasks and sync tasks are both submitted through the Dask task runner. Internally, async tasks are wrapped and run on the worker via
asyncio.run(...)
, while sync tasks are run normally. So from a support perspective, mixing is okay. --- # What this means for your issue This log line:
Copy code
python
INFO | prefect.task_runner.dask - Creating a new Dask cluster with `dask_jobqueue.pbs.PBSCluster`
followed by no workers ever appearing, points much more toward: - PBS job submission / queueing behavior - worker startup failure - scheduler ↔️ worker connectivity issues - cluster configuration issues - or a Dask/PBS integration behavior difference in this execution path rather than async/sync mixing. --- # Important distinction ## Prefect concurrency A flow can orchestrate many tasks concurrently. ## Dask worker execution For those tasks to actually run on PBS workers: - the Dask scheduler must come up - PBS worker jobs must be submitted - workers must connect back to the scheduler - tasks must be submitted via
.submit()
The problem you’ve isolated sounds like the cluster is created, but the worker side never materializes. That’s a cluster/provisioning/connectivity issue, not an async/sync type issue. --- # One caveat about async flows Your master flow being:
Copy code
python
@flow(...)
async def s2_satproc_master(...):
is fine. And having normal tasks like:
Copy code
python
@task
def warmup():
    return "ok"
inside it is also fine. That combination is supported. --- # My recommendation from this new evidence At this point, I would stop focusing on “can I mix async and sync?” and instead focus on: ## 1. Does a minimal sync-only PBS Dask flow work? Create the smallest possible standalone test:
Copy code
python
from prefect import flow, task
from prefect_dask import DaskTaskRunner
from dask_jobqueue import PBSCluster

pbs_cluster_kwargs = {
    # your known-good settings
}

@task
def hello():
    import socket
    return socket.gethostname()

@flow(
    task_runner=DaskTaskRunner(
        cluster_class=PBSCluster,
        cluster_kwargs=pbs_cluster_kwargs,
        adapt_kwargs={"maximum": 2},
    )
)
def test_flow():
    return hello.submit().result()

if __name__ == "__main__":
    print(test_flow())
If this works, then your base PBS+Dask config is okay. ## 2. Compare that minimal test to the failing one If the minimal one works but the more complex master/child version does not, then the issue is architectural rather than async/sync. ## 3. Test async-only separately You can also test:
Copy code
python
@task
async def hello_async():
    import socket
    return socket.gethostname()
If both work standalone, then async/sync mixing is definitely not the root cause. --- # One thing to watch for Even though mixing is supported, where your heavy work lives still matters. For example:
Copy code
python
@flow(...)
def my_flow():
    expensive_python_function()
That runs in the flow process, not on Dask workers. Whereas:
Copy code
python
@task
def expensive_python_function():
    ...

@flow(...)
def my_flow():
    expensive_python_function.submit()
does go through Dask. So if some “plain functions” are just ordinary helper functions called directly from the flow body, those won’t use Dask. That may create the impression that “mixing plain and async functions” is the problem, when really the issue is: - plain helper function called directly → local execution - Prefect task submitted → Dask execution
That distinction is critical. --- # Bottom line Yes, you can mix async and sync tasks in a Prefect flow and still run concurrently with DaskTaskRunner. So your newly isolated symptom: - cluster created - no workers assigned - no work progressing is much more likely due to PBS/Dask cluster startup or worker connectivity than to mixed async/sync task definitions. Useful links: - Prefect Dask integration: prefect-dask docs - Task runners: task runners - Dask adaptive scaling: Dask adaptive scaling If you want, I can help you build a minimal PBS diagnostic flow matrix: - sync flow + sync task - async flow + sync task - sync flow + async task - async flow + async task so you can confirm experimentally which combinations work in your HPC environment.
s
@Marvin Using Prefect client 3.622 and server 3.622 Further testing shows that creation of the
DaskTaskRunner
hangs with no workers assign to my PBS cluster for a specific Unix account. Could you please provide some pointers why this could happen? Maybe permissions in a given area on my filesystem that are preventing the correct operation of the Dask cluster for this specific Unix user.
m
thought for 342.7 seconds
Yes — if it only hangs for one specific Unix account, that strongly points to an account/environment/PBS/filesystem issue, not a Prefect async/sync problem. The most likely causes are: ## Most common account-specific causes ### 1. Shell startup files hanging worker launch Dask-jobqueue workers are often started through shell commands on compute nodes. If that user’s
.bashrc
,
.bash_profile
, or similar contains interactive-only logic, prompts, module loads,
conda activate
, or commands that hang in non-interactive shells, workers may never fully start. A classic fix is adding this at the top of `.bashrc`:
Copy code
bash
[[ $- == *i* ]] || return
That prevents non-interactive worker shells from running interactive startup logic. ### 2. Permissions on temp / working / log directories That user may lack write/execute permissions on: -
/tmp
-
$TMPDIR
- home directory subpaths - scratch/work directories - Dask worker local directories - PBS stdout/stderr log locations If scheduler or worker processes cannot write temp files, job scripts, nanny data, or logs, worker startup may silently fail. ### 3. PBS account / queue / project permissions That Unix account may: - not be allowed on the requested queue - lack allocation/account/project permissions - hit per-user limits - have jobs held or rejected So the scheduler process may start locally, but PBS worker jobs never become active. ### 4. Different Python environment for that user That account may not have the same: -
python
-
prefect
-
prefect-dask
-
dask
-
distributed
-
dask-jobqueue
available on compute nodes as on the login node. Workers can fail if the compute node environment differs. ### 5. Hostname / network / node callback issues Workers must connect back to the Dask scheduler. If that account’s environment, hostname resolution, or bind address is wrong, the scheduler can start but workers cannot attach. --- # Why this matches your symptom You said: -
DaskTaskRunner
says it is creating a
PBSCluster
- then it hangs - no workers get assigned - it is specific to one Unix user That is exactly the pattern of: - scheduler exists - worker PBS jobs do not start or do not connect back --- # Practical debugging checklist ## A. First, test
PBSCluster
outside Prefect This is the best way to separate Prefect from the HPC issue. Create a tiny standalone script for that Unix user:
Copy code
python
from dask_jobqueue import PBSCluster
from distributed import Client

cluster = PBSCluster(
    # use the same settings as your Prefect flow
    # queue=...,
    # account=...,
    # cores=...,
    # memory=...,
    # walltime=...,
)

print("Cluster created")
print(cluster.job_script())

cluster.scale(1)

client = Client(cluster)
print("Dashboard:", client.dashboard_link)
print("Scheduler:", client.scheduler.address)

print(client.scheduler_info())
input("Press Enter to close...")
If this also hangs or never gets workers, the issue is not Prefect. --- ## B. Check whether PBS jobs are actually submitted After
cluster.scale(1)
or adaptive startup, inspect PBS:
Copy code
bash
qstat -u <your_user>
or whatever equivalent your cluster uses. Questions: - Is a worker job submitted at all? - Is it queued forever? - Is it held? - Is it immediately exiting? If jobs are submitted but fail quickly, inspect stdout/stderr logs. --- ## C. Check
.bashrc
/ shell startup Run:
Copy code
bash
timeout 5 /bin/bash -i -c 'echo OK'
and also:
Copy code
bash
bash -lc 'python -V'
If these hang or behave oddly for that account, shell startup is a prime suspect. If needed, simplify
.bashrc
and guard interactive logic:
Copy code
bash
[[ $- == *i* ]] || return
--- ## D. Check temp directories For that Unix user, inspect:
Copy code
bash
echo $TMPDIR
ls -ld /tmp
ls -ld "$HOME"
And verify writable temp/work space:
Copy code
bash
touch /tmp/test_file.$$ && rm /tmp/test_file.$$
mkdir -p "$HOME/tmp_test_dir" && rmdir "$HOME/tmp_test_dir"
Also, if your
PBSCluster
or Dask config uses a local directory, verify it is writable. You may want to explicitly set a worker local directory in your PBS job config if
/tmp
is problematic. --- ## E. Check PBS stdout/stderr locations If the PBS worker job writes logs to a location the user cannot write, jobs may fail at startup. Inspect your
pbs_cluster_kwargs
for anything like: - log directory - worker local dir - job script extras - output/error paths And check permissions. --- ## F. Check the compute-node Python environment For that account, ensure compute nodes can run the same environment. A simple PBS test job can help:
Copy code
bash
#!/bin/bash
#PBS -N test_python_env
#PBS -o /path/to/writeable/stdout.log
#PBS -e /path/to/writeable/stderr.log

which python
python -V
python -c "import prefect, distributed, dask_jobqueue; print('ok')"
Submit that as the same user. If imports fail on compute nodes, Dask workers will fail too. --- ## G. Inspect the Dask worker job script
PBSCluster
can show the job script it wants to submit. In a standalone test:
Copy code
python
print(cluster.job_script())
Look for: - bad paths - missing env activation - wrong queue/account - log paths - startup commands that depend on interactive shell state This is often one of the fastest ways to spot user-specific issues. --- # Things I would especially suspect Given your symptom, I would prioritize these: ## Highest probability 1. Interactive shell config in
.bashrc
/
.bash_profile
2. PBS account/queue/allocation permissions 3. Temp/log directory permissions 4. Compute-node Python env mismatch 5. Worker cannot connect back to scheduler hostname/port --- # Good next commands to run as that user ## Shell sanity
Copy code
bash
timeout 5 bash -lc 'python -V'
timeout 5 bash -lc 'which python'
timeout 5 bash -lc 'python -c "import distributed, dask_jobqueue; print(\"ok\")"'
## Temp dir sanity
Copy code
bash
python -c "import tempfile; print(tempfile.gettempdir())"
touch /tmp/pbs_test.$$ && rm /tmp/pbs_test.$$
## PBS sanity
Copy code
bash
qstat -u $USER
## Compare environment with a working user
Copy code
bash
env | sort
which python
python -V
python -c "import prefect, distributed, dask_jobqueue; print(prefect.__version__)"
--- # Prefect-specific note Since you’ve already isolated that even a simple task on the master’s static
DaskTaskRunner
hangs for this user, the problem is almost certainly below Prefect: - Dask-jobqueue - PBS - account shell/env - filesystem/network permissions So I would stop debugging Prefect orchestration logic until the standalone
PBSCluster + Client
test works for that user. --- # Recommended next step Try this minimal non-Prefect script as the problematic user:
Copy code
python
from dask_jobqueue import PBSCluster
from distributed import Client

cluster = PBSCluster(
    # same kwargs you use in Prefect
)

print("Created cluster")
print(cluster.job_script())

cluster.scale(1)
print("Scaled to 1 worker")

client = Client(cluster)
print("Connected client")
print("Scheduler:", client.scheduler.address)
print("Info:", client.scheduler_info())
If that fails or hangs, you’ve confirmed the issue is in the user/PBS/Dask layer. Useful references: - Dask Jobqueue docs: dask-jobqueue - Prefect Dask integration: prefect-dask docs If you want, I can help you create: 1. a minimal standalone PBSCluster diagnostic script, and 2. a compare-working-user-vs-failing-user checklist tailored to your
pbs_cluster_kwargs
.
s
@Marvin My Dask dashboard says:
Copy code
Progress -- total: 15, waiting: 12, queued:2, processing: 0, in-memory:0, no-worker: 1, erred: 0
m
thought for 17.0 seconds
That’s a very useful clue.
processing: 0
plus
no-worker: 1
means the scheduler has tasks, but at least one task cannot be assigned to any available worker, and currently nothing is actually running. So the important part is: the scheduler is alive and has tasks, but usable workers are not available for those tasks. ## What those fields suggest - total: 15 — 15 tasks known to Dask - waiting: 12 — most tasks are blocked waiting on dependencies - queued: 2 — 2 are ready to run if a worker can take them - processing: 0 — no task is currently executing - in-memory: 0 — no completed results in worker memory - no-worker: 1 — at least one task has no eligible worker That is not a normal healthy steady state if you expect active PBS workers. --- # Most likely meaning in your case For your setup,
no-worker: 1
usually points to one of these: ## 1. There are actually no connected workers Even if a scheduler exists, if no PBS workers have successfully connected, tasks cannot run. This is still consistent with your earlier symptom: - cluster appears created - but PBS workers are not showing up or not attaching ## 2. Worker resources / constraints don’t match the task If tasks were submitted with specific worker restrictions, resource tags, or similar constraints, Dask may refuse to place them on current workers. This is less likely unless you explicitly use worker constraints/resources. ## 3. Workers are starting but immediately failing/disconnecting Then the scheduler can have queued tasks but effectively no usable workers. --- # What I would inspect next on the Dask dashboard ## Check the Workers page Do you see any connected workers? If zero workers are shown, then the issue is straightforward: - scheduler exists - workers never connected If workers appear briefly and disappear, that points to worker startup failure. ## Check the Task Stream / Graph This can confirm whether tasks are simply never starting versus starting and dying. ## Check the Logs if available If worker logs are exposed, they may show: - import errors - permission issues - nanny failures - cannot connect to scheduler - local directory errors --- # This strongly supports the “worker startup problem” hypothesis Combined with everything you reported so far, the dashboard is telling us: - Dask scheduler exists - tasks are reaching it - but there are no usable workers doing work That pushes the problem away from Prefect logic and toward: - PBS jobs not launching - workers not connecting - workers crashing - account-specific environment/permission issues --- # High-probability causes now ## A. No connected PBS workers Your cluster is creating a scheduler but not acquiring usable workers. ## B. Worker startup fails for that account Very plausible given your earlier finding that it is user-specific. Common reasons: -
.bashrc
/
.bash_profile
causing non-interactive startup issues - wrong Python env on compute nodes - permission denied in temp/log/local dirs - queue/account restrictions - hostname/network callback failure ## C. Worker local directory issue Workers can fail immediately if their local working directory is not writable. This is especially worth checking for a single problematic Unix account. --- # What to check immediately ## 1. Are there any workers connected? From within a client context or dashboard worker page. If you can get a Dask client in a working test, inspect:
Copy code
python
info = client.scheduler_info()
print(info["workers"].keys())
If empty, workers never connected. --- ## 2. Check PBS job state for the worker jobs Run something like:
Copy code
bash
qstat -u $USER
Questions: - are worker jobs submitted? - queued forever? - running? - exiting immediately? --- ## 3. Inspect worker stderr/stdout logs If the worker jobs are launched but fail, the log files are often the fastest path to the answer. Look for: -
ModuleNotFoundError
-
Permission denied
-
No such file or directory
-
OSError
- cannot connect to scheduler address - temp dir / nanny / local directory errors --- ## 4. Check whether
/tmp
or worker local dirs are writable For the failing user:
Copy code
bash
python -c "import tempfile; print(tempfile.gettempdir())"
touch /tmp/test.$$ && rm /tmp/test.$$
If your jobqueue config sets a local directory, check that too. --- ## 5. Check the user shell startup files Still one of the top suspects for account-specific issues. Try:
Copy code
bash
timeout 5 bash -lc 'python -V'
timeout 5 bash -lc 'python -c "import distributed, dask_jobqueue; print(\"ok\")"'
If those hang or fail, worker startup likely does too. --- # What
no-worker: 1
does not suggest It does not primarily suggest: - a Prefect async/sync bug -
asyncio.as_completed
being the cause -
run_deployment
parameter passing being broken Those are increasingly unlikely now. --- # Practical interpretation Your system is now at the point where: Prefect is getting tasks to Dask, but Dask does not have a usable worker to run them. That’s actually progress in diagnosis. --- # Best next debugging move I’d focus on a minimal standalone Dask-jobqueue test for that Unix account and inspect: - whether a worker PBS job is submitted - whether it starts - whether it connects to the scheduler - whether it dies immediately Because your dashboard state is exactly what you’d expect from “scheduler only, no usable worker”. --- If you want, I can give you a very small standalone
PBSCluster
diagnostic script
that: - creates one worker - prints the generated PBS job script - waits for a worker to connect - and logs enough information to compare working vs failing users.
s
@Marvin Issue has been resolved. There was nothing wrong with the
DaskTaskRunner
implementation, but rather a fatal error inside the PBS script in the prologue section. But now that I am able to succesfully share my master cluster with the child deployment, I am having a racing issue in the
prefect.deployments.steps.git_clone
section as each submitted deployment run competing for the same clone directory. This is how my deployment yaml looks like:
Copy code
name: overnight_prefect
prefect-version: 3.6.22
build: null
push: null
pull:
    - prefect.deployments.steps.git_clone:
        repository:  <https://gitlab.com/my_project.git>
        branch: my-feat-branch
        credentials: "{{ prefect.blocks.gitlab-credentials.my-token }}"


- name: s2_satproc_shared_dev
  version: null
  tags: []
  concurrency_limit: null
  description: "Creating sentinel2 QVF downstream products - shared cluster deployment."
  entrypoint: flows/child_flow.py:sen2_satproc_shared
  parameters: {
    "import_file": "test.img",
    "working_dir": "/my_scratch/dir",
    "overwrite": false
  }
  work_pool:
    name: athena_dev
    work_queue_name: null
    job_variables:
      working_dir: "{{ prefect.variables.satproc_workdir_dev }}"
      env:
          RSS_DEFAULT_FILEMODE: '644'
          PREFECT_PYTHON_CMD: 'x/x/xx/python'
          SATPROC_SHARED_DIR: "{{ prefect.variables.satproc_workdir_dev }}"
          DASK_SCHEDULER_ADDRESS: null
          GIT_TERMINAL_PROMPT: '0'
And rememberng my master.py flow:
Copy code
@flow(
    log_prints=True,
    task_runner=DaskTaskRunner(  # type: ignore[arg-type]
        cluster_class=PBSCluster,
        cluster_kwargs=pbs_cluster_kwargs,
        adapt_kwargs={"maximum": MAX_WORKERS}
    )
)
async def s2_satproc_master(
    files_list: list[str],
    working_dir: Path,
    overwrite: bool = False,
    qvf_stages_out: set[str] = QVF_STAGES_OUT,
    env_state: str = 'dev'
) -> None:# and trigger the child (sub) flows
    """_summary_

    Args:
        files_list (list[str]): _description_
        working_dir (Path): _description_
        overwrite: _description_
        qvf_stages_out: _description_
        env_state (str, optional): _description_. Defaults to 'dev'.
    """
    logger = get_run_logger()

    qvf_imports = get_unique_qvf_names.submit(files_list).result()
    logger.info(f"{len(qvf_imports)} files to process.")

    # ...

    # get cluster and share address with child flows
    flow_ctx = FlowRunContext.get()
    if flow_ctx is None:
        raise RuntimeError("Could not access FlowRunContext in master flow.")


    task_runner = flow_ctx.task_runner
    scheduler_address = task_runner.client.scheduler.address  # type: ignore[attr-defined]
    dashboard_link = task_runner.client.dashboard_link  # type: ignore[attr-defined]

    logger.info("Created shared Dask scheduler at %s", scheduler_address)
    logger.info("Dask dashboard available at %s",   dashboard_link)

    # collect all flow run submissions
    submitted_runs: list[dict] = []
    for f_process in to_process:
        logger.info(f"submitting processing for {f_process.infile}")
        # we run several child flows, getting their results concurrently
        main_params = {
            "import_file": f_process.infile,
            "working_dir": working_dir,
            "overwrite": overwrite,
        }

        flow_run = await run_deployment(  # type: ignore[misc]
            name=f"sen2-satproc-shared/s2_satproc_shared_{env_state}",
            parameters=main_params,
            timeout=0,
            job_variables={
                "env": {
                    "DASK_SCHEDULER_ADDRESS": scheduler_address,
                    }
                }
        )
        logger.info(f"submitted flow run {flow_run.name} - {flow_run.id} for {f_process.infile}")

        submitted_runs.append(
            {
                "flow_run_id": flow_run.id,
                "import_file": f_process.infile
            }
        )
m
thought for 18.7 seconds
Great find — and that also explains a lot. If the fatal error was in the PBS prologue, then the
DaskTaskRunner
behavior you were seeing was just a downstream symptom. Now for the new issue: ## What’s happening Your deployment uses the pull step:
Copy code
yaml
pull:
  - prefect.deployments.steps.git_clone:
      repository: <https://gitlab.com/my_project.git>
      branch: my-feat-branch
      credentials: "{{ prefect.blocks.gitlab-credentials.my-token }}"
and your master launches many child deployment runs concurrently. That means each run is trying to execute the same
git_clone
pull step, likely into the same working directory on the same process worker host/filesystem. So yes — this can create a race: - one run clones - another run tries to clone/update the same path - filesystem state collides - checkout/pull/cleanup fails or becomes inconsistent --- # Why this happens
prefect.deployments.steps.git_clone
is a deployment pull step, and deployment pull steps happen per flow run. If many flow runs share: - the same worker host - the same
working_dir
- the same pull destination then concurrent cloning into the same directory is unsafe. --- # Best ways to fix it ## Option 1 — Best for your case: pre-stage the repo on the worker and remove
git_clone
If this is an HPC/process-worker environment and the worker is already running in a stable project directory, the cleanest approach is often: - clone/update the repo outside Prefect - point the worker
working_dir
at that checked-out repo - remove
prefect.deployments.steps.git_clone
This avoids per-run Git races entirely. ### Good when: - you control the worker host - the code version is stable enough per deployment cycle - you can update the checkout out-of-band This is often the most reliable approach on HPC/shared filesystem setups. --- ## Option 2 — Use separate per-run clone directories If you need pull-at-runtime behavior, then each run needs an isolated clone target. The tricky part is that the stock
git_clone
step is not really designed for “many concurrent runs cloning into unique per-run dirs” unless you make the working directory unique per run. So you’d want each flow run to get its own directory, something like:
Copy code
yaml
job_variables:
  working_dir: /my_scratch/dir/prefect-run-<unique-id>
However, to make that truly unique per run, you need a runtime-specific value. In practice, whether that is easy depends on your worker/job template setup. For process workers, this can be awkward. --- ## Option 3 — Reduce child deployment startup concurrency If the clone step is the only hot spot, you can throttle the number of child deployments launched at once. Instead of firing all runs immediately: - launch in batches - or apply a deployment concurrency limit This reduces contention, but it does not eliminate the root race if multiple runs still share the same clone path. So this is a mitigation, not the best fix. --- ## Option 4 — Avoid deployment-level clone for the child flow altogether Since your master is already orchestrating many child runs, another pattern is: - keep the child deployment code locally available to the worker - use deployment metadata only for scheduling/execution - do not require each child run to independently fetch code Again, this is conceptually similar to Option 1. --- # My recommendation For your setup, I would recommend: ## Use a shared pre-cloned repo on the worker filesystem and remove the
git_clone
pull step.
Because you have: - a process worker - many child runs launched concurrently - shared filesystem - HPC environment This is exactly the kind of environment where per-run Git clone/update steps are fragile. --- # What that looks like ## Instead of this
Copy code
yaml
pull:
  - prefect.deployments.steps.git_clone:
      repository: <https://gitlab.com/my_project.git>
      branch: my-feat-branch
      credentials: "{{ prefect.blocks.gitlab-credentials.my-token }}"
## Prefer this
Copy code
yaml
pull: []
or omit
pull
entirely, and ensure the worker
working_dir
points at a repo checkout that already exists and is up to date. Example:
Copy code
yaml
work_pool:
  name: athena_dev
  job_variables:
    working_dir: /my_scratch/dir/my_project_checkout
Then the entrypoint:
Copy code
yaml
entrypoint: flows/child_flow.py:sen2_satproc_shared
is resolved from that local checkout. --- # Operationally You’d update the repo on the worker side with something like:
Copy code
bash
cd /my_scratch/dir/my_project_checkout
git fetch
git checkout my-feat-branch
git pull
before starting/running the worker, or as part of your own deployment pipeline. --- # Why this is better than dynamic per-run cloning Because your master may submit many child runs in a short burst:
Copy code
python
for f_process in to_process:
    flow_run = await run_deployment(...)
If every one of those child runs has to do a Git clone/pull step, you’ve introduced a completely unnecessary shared-filesystem contention point right at run startup. That’s especially risky on HPC/shared storage. --- # If you must keep
git_clone
Then I’d suggest one of these mitigations: ## A. Give each child run a unique working directory This is the right technical fix if you insist on runtime Git clone. But it depends on whether your worker job configuration can produce unique run-specific directories. ## B. Batch launches Launch only a few child deployments at a time. ## C. Add deployment concurrency limits This can help reduce simultaneous clone collisions. But again, these are mitigations. --- # My concrete suggestion for your YAML ## Prefer something like this
Copy code
yaml
name: overnight_prefect
prefect-version: 3.6.22
build: null
push: null
pull: []

deployments:
  - name: s2_satproc_shared_dev
    description: "Creating sentinel2 QVF downstream products - shared cluster deployment."
    entrypoint: flows/child_flow.py:sen2_satproc_shared
    parameters:
      import_file: "test.img"
      working_dir: "/my_scratch/dir"
      overwrite: false
    work_pool:
      name: athena_dev
      work_queue_name: null
      job_variables:
        working_dir: "/my_scratch/dir/my_project_checkout"
        env:
          RSS_DEFAULT_FILEMODE: "644"
          PREFECT_PYTHON_CMD: "x/x/xx/python"
          SATPROC_SHARED_DIR: "{{ prefect.variables.satproc_workdir_dev }}"
          DASK_SCHEDULER_ADDRESS: null
          GIT_TERMINAL_PROMPT: "0"
Where
/my_scratch/dir/my_project_checkout
is an already-cloned copy of the repo. --- # Alternative: use source-based deployment once, not per-run git clone If you’re open to a different deployment style, Prefect 3 also supports source-based patterns like
flow.from_source(...)
when building deployments, which can be cleaner than old block-based Git patterns. But for your HPC burst-concurrency case, even then, the key idea remains: avoid many runs mutating the same checkout path at startup. Docs: - Prefect 3 deployment docs --- # Summary Your new issue is a classic shared-directory race from concurrent per-run
git_clone
. ## Best fix Pre-clone the repo into a stable worker directory and remove the
git_clone
pull step.
## Second-best fix Give each run its own isolated working directory / clone target. ## Mitigation only Throttle the number of child runs starting at once. If you want, I can help you rewrite your
prefect.yaml
into a worker-local checkout version and show how I’d structure: - master deployment - child deployment - worker
working_dir
for this HPC/shared-cluster setup.
s
@Marvin Yeah... show me your idea working with a Prefect process worker for:
Copy code
If you want, I can help you rewrite your prefect.yaml into a worker-local checkout version and show how I'd structure:
- master deployment
- child deployment
- worker working_dir
for this HPC/shared-cluster setup.
I tried breaking up
s2_satproc_shared_dev
into a separate yaml, but found out that having
pull: []
effectively defaults to:
Copy code
[
  {
    "prefect.deployments.steps.set_working_directory": {
      "directory": "/my/current/flow_local_exec_directory"
    }
  }
]
One of the nice things about
prefect.deployments.steps.git_clone
is the ability to grab code from a feature branch. How can I achieve something similar if I am breaking up master/child deployment yaml definitions?
m
thought for 31.0 seconds
Yes — for a process worker, the clean pattern is usually: - the worker runs from a pre-existing local checkout - deployments point at files in that checkout - updating to a feature branch is done by updating the checkout itself, not by per-run
git_clone
And yes, your observation is right: if you omit
pull
, Prefect effectively uses the current working directory context, which can surprise you if your worker starts from the “wrong” place. So the solution is not just “remove `git_clone`”, but: make the process worker’s
working_dir
point at a specific local repo checkout that you control.
That gets you: - no clone races - support for feature branches - separate master/child deployment YAML if you want - stable entrypoint resolution --- # Recommended structure for a process worker ## Core idea Instead of this: - every flow run clones the repo at startup do this: - create one or more local repo checkouts on disk - start/use the process worker with
working_dir
pointing at the desired checkout - deploy flows whose entrypoints are relative to that checkout That way, “use a feature branch” becomes: - update that checkout to the feature branch - then run deployments from it --- # Example local checkout layout On the worker host/shared filesystem, something like:
Copy code
text
/my_scratch/prefect-code/
  satproc-main/
  satproc-my-feat-branch/
Each is a normal Git checkout. For example:
Copy code
bash
cd /my_scratch/prefect-code
git clone <https://gitlab.com/my_project.git> satproc-main
cd satproc-main
git checkout main

git clone <https://gitlab.com/my_project.git> satproc-my-feat-branch
cd satproc-my-feat-branch
git checkout my-feat-branch
Then your process worker / deployment can point at whichever checkout you want. --- # How to preserve “feature branch” convenience ## Best approach Use separate local checkouts per branch/environment. For example: -
/my_scratch/prefect-code/satproc-dev
-
/my_scratch/prefect-code/satproc-my-feat-branch
Then in deployment config, set:
Copy code
yaml
job_variables:
  working_dir: /my_scratch/prefect-code/satproc-my-feat-branch
Now the worker executes from that feature-branch checkout, with no per-run Git operation. That gives you the same practical effect as
git_clone(branch=...)
, but without the concurrency race. --- # How I’d structure this You asked specifically for: - master deployment - child deployment - worker
working_dir
Here’s the pattern I’d use. --- # Option A: One
prefect.yaml
, multiple deployments, branch-specific checkout via
job_variables.working_dir
This is probably the simplest. ## Example
prefect.yaml
```yaml name: overnight_prefect prefect-version: 3.6.22 build: [] push: [] pull: [] deployments: - name: s2_satproc_master_dev description: Master flow that creates a shared PBS-backed Dask cluster and launches child deployments. entrypoint: flows/master_flow.py:s2_satproc_master parameters: files_list: [] working_dir: "/my_scratch/data" overwrite: false env_state: "dev" work_pool: name: athena_dev job_variables: working_dir: /my_scratch/prefect-code/satproc-my-feat-branch env: RSS_DEFAULT_FILEMODE: "644" PREFECT_PYTHON_CMD: "/x/x/xx/python" SATPROC_SHARED_DIR: "{{ prefect.variables.satproc_workdir_dev }}" GIT_TERMINAL_PROMPT: "0" - name: s2_satproc_shared_dev description: Child flow that connects to the shared Dask scheduler provided by the master deployment. entrypoint: flows/child_flow.py:sen2_satproc_shared parameters: import_file: "test.img" working_dir: "/my_scratch/data" overwrite: false work_pool: name: athena_dev job_variables: working_dir: /my_scratch/prefect-code/satproc-my-feat-branch env: RSS_DEFAULT_FILEMODE: "644" PREFECT_PYTHON_CMD: "/x/x/xx/python"
SATPROC_SHARED_DIR: "{{ prefect.variables.satproc_workdir_dev }}" DASK_SCHEDULER_ADDRESS: null GIT_TERMINAL_PROMPT: "0"
Copy code
## Why this works
- no `git_clone`
- both master and child resolve entrypoints from the same local branch checkout
- no clone race
- branch selection is controlled by `working_dir`

---

# Option B: Separate YAMLs for master and child

If you want separate deployment files, that’s fine too.

The key is the same: *both point to a controlled local checkout path*.

## `prefect.master.yaml`

```yaml
name: overnight_prefect_master
prefect-version: 3.6.22

build: []
push: []
pull: []

deployments:
  - name: s2_satproc_master_dev
    entrypoint: flows/master_flow.py:s2_satproc_master
    parameters:
      files_list: []
      working_dir: "/my_scratch/data"
      overwrite: false
      env_state: "dev"
    work_pool:
      name: athena_dev
      job_variables:
        working_dir: /my_scratch/prefect-code/satproc-my-feat-branch
        env:
          RSS_DEFAULT_FILEMODE: "644"
          PREFECT_PYTHON_CMD: "/x/x/xx/python"
          SATPROC_SHARED_DIR: "{{ prefect.variables.satproc_workdir_dev }}"
##
prefect.child.yaml
Copy code
yaml
name: overnight_prefect_child
prefect-version: 3.6.22

build: []
push: []
pull: []

deployments:
  - name: s2_satproc_shared_dev
    entrypoint: flows/child_flow.py:sen2_satproc_shared
    parameters:
      import_file: "test.img"
      working_dir: "/my_scratch/data"
      overwrite: false
    work_pool:
      name: athena_dev
      job_variables:
        working_dir: /my_scratch/prefect-code/satproc-my-feat-branch
        env:
          RSS_DEFAULT_FILEMODE: "644"
          PREFECT_PYTHON_CMD: "/x/x/xx/python"
          SATPROC_SHARED_DIR: "{{ prefect.variables.satproc_workdir_dev }}"
          DASK_SCHEDULER_ADDRESS: null
That’s perfectly valid. --- # Important point about
pull: []
You noticed:
having
pull: []
effectively defaults to
set_working_directory
That’s consistent with Prefect needing some code location context. So the practical fix is: - don’t rely on the shell’s current directory - explicitly set
job_variables.working_dir
to the repo checkout you want That makes the location deterministic. --- # How to work on feature branches cleanly ## Recommended operational pattern ### Option 1: Separate checkout per branch This is what I’d use on HPC. Example:
Copy code
text
/my_scratch/prefect-code/
  satproc-main/
  satproc-dev/
  satproc-my-feat-branch/
Then switch deployments by changing:
Copy code
yaml
job_variables:
  working_dir: /my_scratch/prefect-code/satproc-my-feat-branch
### Option 2: One checkout, manually switch branches Possible, but risky if a worker is actively using it. I would avoid this if runs can overlap. ### Option 3: Symlink “current” Example:
Copy code
text
/my_scratch/prefect-code/current -> /my_scratch/prefect-code/satproc-my-feat-branch
Then deployments always use:
Copy code
yaml
job_variables:
  working_dir: /my_scratch/prefect-code/current
This is convenient, but only safe if you control when the symlink target changes. For concurrent production runs, separate fixed directories are safer. --- # How the process worker fits in You asked specifically about working with a Prefect process worker. The worker itself is started with something like:
Copy code
bash
prefect worker start --pool athena_dev
I verified
prefect worker start
exists. For process workers, the actual run environment is strongly influenced by the work pool’s base job template and
job_variables
. So the important thing is that each deployment sets:
Copy code
yaml
job_variables:
  working_dir: /my_scratch/prefect-code/satproc-my-feat-branch
That ensures the process executing the flow run starts from the right checkout. --- # How master and child stay aligned Since your master does:
Copy code
python
await run_deployment(
    name=f"sen2-satproc-shared/s2_satproc_shared_{env_state}",
    ...
)
you want:
- master deployment and child deployment to reference compatible code - preferably the same branch checkout So I’d set the same
working_dir
for both deployments. That way: -
master_flow.py
-
child_flow.py
come from the same local checkout revision. --- # My preferred HPC-safe layout ## Worker filesystem
Copy code
text
/my_scratch/prefect-code/satproc-my-feat-branch/
    prefect.yaml
    flows/
      master_flow.py
      child_flow.py
## Deployments Both master and child use:
Copy code
yaml
job_variables:
  working_dir: /my_scratch/prefect-code/satproc-my-feat-branch
## Updates to feature branch You update that checkout explicitly:
Copy code
bash
cd /my_scratch/prefect-code/satproc-my-feat-branch
git fetch
git checkout my-feat-branch
git pull
Then redeploy if needed. No runtime clone race. --- # If you want branch selection to remain configurable You can even template branch-specific working dirs using Prefect variables, e.g.:
Copy code
yaml
job_variables:
  working_dir: "{{ prefect.variables.satproc_code_dir_dev }}"
Then change the variable to point to a different local checkout. Example variable values: -
/my_scratch/prefect-code/satproc-main
-
/my_scratch/prefect-code/satproc-my-feat-branch
That’s a nice replacement for “choose branch at clone time”. --- # Final recommendation For your process-worker + HPC + bursty child deployment pattern, I would use: -
pull: []
- no
git_clone
- branch-specific local checkouts - explicit
job_variables.working_dir
- same checkout path for both master and child deployments - optionally Prefect variables to switch checkout paths cleanly --- # Example with Prefect variable for code checkout
Copy code
yaml
name: overnight_prefect
prefect-version: 3.6.22

build: []
push: []
pull: []

deployments:
  - name: s2_satproc_master_dev
    entrypoint: flows/master_flow.py:s2_satproc_master
    parameters:
      files_list: []
      working_dir: "/my_scratch/data"
      overwrite: false
      env_state: "dev"
    work_pool:
      name: athena_dev
      job_variables:
        working_dir: "{{ prefect.variables.satproc_code_checkout_dev }}"
        env:
          RSS_DEFAULT_FILEMODE: "644"
          PREFECT_PYTHON_CMD: "/x/x/xx/python"
          SATPROC_SHARED_DIR: "{{ prefect.variables.satproc_workdir_dev }}"

  - name: s2_satproc_shared_dev
    entrypoint: flows/child_flow.py:sen2_satproc_shared
    parameters:
      import_file: "test.img"
      working_dir: "/my_scratch/data"
      overwrite: false
    work_pool:
      name: athena_dev
      job_variables:
        working_dir: "{{ prefect.variables.satproc_code_checkout_dev }}"
        env:
          RSS_DEFAULT_FILEMODE: "644"
          PREFECT_PYTHON_CMD: "/x/x/xx/python"
          SATPROC_SHARED_DIR: "{{ prefect.variables.satproc_workdir_dev }}"
          DASK_SCHEDULER_ADDRESS: null
Set
prefect.variables.satproc_code_checkout_dev
to something like:
Copy code
text
/my_scratch/prefect-code/satproc-my-feat-branch
--- Useful links: - Prefect deployments: deployment docs - Process worker CLI: workers docs If you want, I can go one step further and give you: - a full example directory layout - plus separate master/child YAMLs - plus a suggested branch promotion workflow for feature branch → dev → main on a process worker host.
s
@Marvin I think manually preparing the code repo for the Process worker is suboptimal. I rather not go that way. But, maybe I can programmatically ask to clone the repo (with feat branch as an option param) in the
master.py
something like:
Copy code
@flow(
    log_prints=True,
    task_runner=DaskTaskRunner(  # type: ignore[arg-type]
        cluster_class=PBSCluster,
        cluster_kwargs=pbs_cluster_kwargs,
        adapt_kwargs={"maximum": MAX_WORKERS}
    )
)
async def s2_satproc_master(
    files_list: list[str],
    working_dir: Path,
    overwrite: bool = False,
    qvf_stages_out: set[str] = QVF_STAGES_OUT,
    env_state: str = 'dev',
    my_branch_name: str = 'my_branch_name'
) -> None:# and trigger the child (sub) flows
    """
    """
    logger = get_run_logger()

    qvf_imports = get_unique_qvf_names.submit(files_list).result()
    <http://logger.info|logger.info>(f"{len(qvf_imports)} files to process.")

    # ...

    # get cluster and share address with child flows
    flow_ctx = FlowRunContext.get()
    if flow_ctx is None:
        raise RuntimeError("Could not access FlowRunContext in master flow.")


    task_runner = flow_ctx.task_runner
    scheduler_address = task_runner.client.scheduler.address  # type: ignore[attr-defined]
    dashboard_link = task_runner.client.dashboard_link  # type: ignore[attr-defined]

    <http://logger.info|logger.info>("Created shared Dask scheduler at %s", scheduler_address)
    <http://logger.info|logger.info>("Dask dashboard available at %s",   dashboard_link)
    
    i = 0
    # collect all flow run submissions
    submitted_runs: list[dict] = []
    for f_process in to_process:
        if i == 0:
            local_clone = clone_repor(branch=my_branch_name)  # this funnction will do the clone and return the local copy
            # Then use Prefect variable to set the value of say 'shared_local_clone'

        <http://logger.info|logger.info>(f"submitting processing for {f_process.infile}")
        # we run several child flows, getting their results concurrently
        main_params = {
            "import_file": f_process.infile,
            "working_dir": working_dir,
            "overwrite": overwrite,
        }

        flow_run = await run_deployment(  # type: ignore[misc]
            name=f"sen2-satproc-shared/s2_satproc_shared_{env_state}",
            parameters=main_params,
            timeout=0,
            job_variables={
                "env": {
                    "DASK_SCHEDULER_ADDRESS": scheduler_address,
                    }
                }
        )
        <http://logger.info|logger.info>(f"submitted flow run {flow_run.name} - {flow_run.id} for {f_process.infile}")

        submitted_runs.append(
            {
                "flow_run_id": flow_run.id,
                "import_file": f_process.infile
            }
        )
And my isolated 'shared' deployment yaml could look something like:
Copy code
name: overnight_prefect
prefect-version: 3.6.22
build: null
push: null
pull:
  - prefect.deployments.steps.set_working_directory:
      directory: "{{ prefect.variables.shared_local_clone }}"


- name: s2_satproc_shared_dev
  version: null
  tags: []
  concurrency_limit: null
  description: "Creating sentinel2 QVF downstream products - shared cluster deployment."
  entrypoint: flows/child_flow.py:sen2_satproc_shared
  parameters: {
    "import_file": "test.img",
    "working_dir": "/my_scratch/dir",
    "overwrite": false
  }
  work_pool:
    name: athena_dev
    work_queue_name: null
    job_variables:
      working_dir: "{{ prefect.variables.satproc_workdir_dev }}"
      env:
          RSS_DEFAULT_FILEMODE: '644'
          PREFECT_PYTHON_CMD: 'x/x/xx/python'
          SATPROC_SHARED_DIR: "{{ prefect.variables.satproc_workdir_dev }}"
          DASK_SCHEDULER_ADDRESS: null
          GIT_TERMINAL_PROMPT: '0'
m
thought for 21.8 seconds
Yes — that idea is conceptually viable, and it’s better than having every child run race through
git_clone
. But I’d refine it a bit, because using a single global Prefect variable like
shared_local_clone
introduces a new race if two master runs overlap. ## The good part of your idea You’re moving from: - each child deployment clones the repo to: - the master prepares one local clone once - all child deployments use that prepared clone That’s a much better architecture. --- # The main risk in your current proposal This part is risky:
Copy code
yaml
pull:
  - prefect.deployments.steps.set_working_directory:
      directory: "{{ prefect.variables.shared_local_clone }}"
if
shared_local_clone
is a single shared Prefect variable. Why? Because if two master runs happen at the same time: - master A sets
shared_local_clone=/path/clone_A
- master B sets
shared_local_clone=/path/clone_B
- child runs from master A and B can now interfere and pick up the wrong code checkout So a global mutable variable is not a safe handoff mechanism for run-specific code paths. --- # Better version of your idea Instead of using a Prefect variable for the clone path, make the clone path per master run and pass it directly to child deployments via
job_variables.working_dir
. That avoids global state entirely. ## In other words ### Master - clone repo once into a unique directory, e.g. -
/scratch/prefect-code/run-<flow_run_id>
- then call child deployment with:
Copy code
python
job_variables={
    "working_dir": local_clone_path,
    "env": {
        "DASK_SCHEDULER_ADDRESS": scheduler_address,
    },
}
### Child deployment YAML Use no
git_clone
, and no
set_working_directory
based on a global variable. Instead, let the runtime job variable override provide the
working_dir
. That is much safer. --- # Why this is better Because the clone location becomes: - unique per master run - isolated from other master runs - directly tied to the child runs it spawned No shared mutable variable required. --- # Recommended design ## Child deployment YAML I would simplify it to this:
Copy code
yaml
name: overnight_prefect
prefect-version: 3.6.22
build: []
push: []
pull: []

deployments:
  - name: s2_satproc_shared_dev
    description: "Creating sentinel2 QVF downstream products - shared cluster deployment."
    entrypoint: flows/child_flow.py:sen2_satproc_shared
    parameters:
      import_file: "test.img"
      working_dir: "/my_scratch/dir"
      overwrite: false
    work_pool:
      name: athena_dev
      job_variables:
        working_dir: /tmp/default-placeholder
        env:
          RSS_DEFAULT_FILEMODE: "644"
          PREFECT_PYTHON_CMD: "x/x/xx/python"
          SATPROC_SHARED_DIR: "{{ prefect.variables.satproc_workdir_dev }}"
          DASK_SCHEDULER_ADDRESS: null
          GIT_TERMINAL_PROMPT: "0"
### Notes -
pull: []
- no
git_clone
- no global
shared_local_clone
-
working_dir
is just a placeholder default - the master overrides it per child run --- # Master flow pattern Your master can do this: 1. create a unique clone path 2. clone branch there once 3. reuse that path for all child runs launched by that master run ## Example structure
Copy code
python
from pathlib import Path
import subprocess
import uuid

def clone_repo(branch: str, base_dir: Path) -> Path:
    clone_dir = base_dir / f"prefect-run-{uuid.uuid4().hex}"

    subprocess.run(
        [
            "git",
            "clone",
            "--branch",
            branch,
            "<https://gitlab.com/my_project.git>",
            str(clone_dir),
        ],
        check=True,
    )

    return clone_dir
Then in the master:
Copy code
python
local_clone = clone_repo(branch=my_branch_name, base_dir=Path("/my_scratch/prefect-code"))
<http://logger.info|logger.info>("Prepared local clone at %s", local_clone)
Then pass it into each child deployment: ```python flow_run = await run_deployment(
name=f"sen2-satproc-shared/s2_satproc_shared_{env_state}", parameters=main_params, timeout=0, job_variables={ "working_dir": str(local_clone), "env": { "DASK_SCHEDULER_ADDRESS": scheduler_address, }, }, )
Copy code
That is the key change.

---

# Why `job_variables.working_dir` is the right place

Because for a process worker, the worker job/run process can be told to execute from that working directory for that specific run.

That avoids needing:
- a pull step
- a shared variable
- a checkout preinstalled manually

It also preserves your branch-selection idea:
- branch becomes a master flow parameter
- master clones that branch once
- all children for that master run use that clone

---

# Strong caveat

This is better than per-child `git_clone`, but it still has a tradeoff:

## You are now doing Git clone *from inside the master flow runtime*
That means:
- the master run host/process must have Git/network credentials
- the clone location must be visible to the child worker processes
- cleanup becomes your responsibility

So it’s operationally more complex than a fixed local checkout, but still much safer than concurrent child clones.

---

# Very important filesystem requirement

This only works if the clone path created by the master is visible to the child deployment runtime.

For a process worker, that usually means:
- master and child runs are on the same machine or shared filesystem
- the path is accessible to the worker process that runs the child

If the child process runs elsewhere without shared visibility, this will fail.

Given your HPC/shared filesystem setup, it sounds plausible that this is okay.

---

# Cleanup strategy

Since you’re creating per-master-run clones, you should clean them up eventually.

You have two options:

## Option A: cleanup in master `finally`
After all children complete:

```python
shutil.rmtree(local_clone, ignore_errors=True)
## Option B: keep clones temporarily for debugging Then delete them via a separate cleanup job / cron / retention policy During development, I’d probably keep them until the run completes successfully. --- # Recommended master flow shape Here’s the pattern I’d actually use. ```python from pathlib import Path import shutil import subprocess import uuid from prefect import flow, get_run_logger from prefect.context import FlowRunContext from prefect.deployments.flow_runs import run_deployment from prefect_dask import DaskTaskRunner from dask_jobqueue import PBSCluster MAX_WORKERS = 20 REPO_URL = "https://gitlab.com/my_project.git" def clone_repo(branch: str, base_dir: Path) -> Path: clone_dir = base_dir / f"prefect-run-{uuid.uuid4().hex}" subprocess.run( [ "git", "clone", "--branch", branch, REPO_URL, str(clone_dir), ], check=True, ) return clone_dir @flow( log_prints=True, task_runner=DaskTaskRunner( cluster_class=PBSCluster, cluster_kwargs=pbs_cluster_kwargs, adapt_kwargs={"maximum": MAX_WORKERS}, ), ) async def s2_satproc_master( files_list: list[str], working_dir: Path, overwrite: bool = False, env_state: str = "dev", my_branch_name: str = "my-feat-branch", ) -> None: logger = get_run_logger() local_clone = None try: local_clone = clone_repo( branch=my_branch_name, base_dir=Path("/my_scratch/prefect-code"), ) logger.info("Prepared local clone for child deployments at %s", local_clone) flow_ctx = FlowRunContext.get() if flow_ctx is None: raise RuntimeError("Could not access FlowRunContext in master flow.") task_runner = flow_ctx.task_runner scheduler_address = task_runner.client.scheduler.address dashboard_link = task_runner.client.dashboard_link
logger.info("Created shared Dask scheduler at %s", scheduler_address) logger.info("Dask dashboard available at %s", dashboard_link) submitted_runs = [] for infile in files_list: main_params = { "import_file": infile, "working_dir": str(working_dir), "overwrite": overwrite, } flow_run = await run_deployment( name=f"sen2-satproc-shared/s2_satproc_shared_{env_state}", parameters=main_params, timeout=0, job_variables={ "working_dir": str(local_clone), "env": { "DASK_SCHEDULER_ADDRESS": scheduler_address, }, }, ) logger.info( "Submitted flow run %s - %s for %s", flow_run.name, flow_run.id, infile, ) submitted_runs.append( { "flow_run_id": flow_run.id, "import_file": infile, } ) # wait for submitted runs here finally: if local_clone is not None: logger.info("Cleaning up local clone %s", local_clone) shutil.rmtree(local_clone, ignore_errors=True)
Copy code
---

# Best YAML for this pattern

## Child deployment YAML

```yaml
name: overnight_prefect
prefect-version: 3.6.22
build: []
push: []
pull: []

deployments:
  - name: s2_satproc_shared_dev
    description: "Creating sentinel2 QVF downstream products - shared cluster deployment."
    entrypoint: flows/child_flow.py:sen2_satproc_shared
    parameters:
      import_file: "test.img"
      working_dir: "/my_scratch/dir"
      overwrite: false
    work_pool:
      name: athena_dev
      job_variables:
        working_dir: /tmp/prefect-placeholder
        env:
          RSS_DEFAULT_FILEMODE: "644"
          PREFECT_PYTHON_CMD: "x/x/xx/python"
          SATPROC_SHARED_DIR: "{{ prefect.variables.satproc_workdir_dev }}"
          DASK_SCHEDULER_ADDRESS: null
          GIT_TERMINAL_PROMPT: "0"
Then the real working dir is injected by the master’s
run_deployment(..., job_variables=...)
. --- # Answer to your feature-branch question
One of the nice things about
git_clone
is the ability to grab code from a feature branch. How can I achieve something similar?
With this pattern: -
my_branch_name
becomes a parameter on
master_flow
- master clones that branch once - child runs execute from that clone path So you preserve branch selection, just moving it from deployment pull step to master-run orchestration logic. --- # Final recommendation I think this is your best non-manual alternative: Clone once per master run into a unique directory, then pass that directory to all child deployment runs via
job_variables.working_dir
.
It avoids: - per-child clone races - shared mutable Prefect variable races - manual pre-staging while still supporting dynamic branch selection. --- # One caution Because this relies on runtime
job_variables.working_dir
, I would test once with a tiny child flow and confirm the process worker really honors that override exactly as expected in your pool template. Given your earlier success with
job_variables.env
, I think it’s a reasonable next test. Useful docs: - Prefect deployments: deployment docs - Workers/work pools: workers docs If you want, I can refine this into: - a production-safe
clone_repo
helper
with credentials handling and branch validation - and a final master/child YAML + code pair for this exact strategy.
s
@Marvin Why is this not working:
Copy code
from prefect.deployments.steps.pull import git_clone
from prefect_gitlab import GitLabCredentials

@flow(
    log_prints=True,
    task_runner=DaskTaskRunner(  # type: ignore[arg-type]
        cluster_class=PBSCluster,
        cluster_kwargs=pbs_cluster_kwargs,
        adapt_kwargs={"maximum": MAX_WORKERS}
    )
)
async def s2_satproc_master(
    files_list: list[str],
    working_dir: Path,
    overwrite: bool = False,
    qvf_stages_out: set[str] = QVF_STAGES_OUT,
    env_state: str = 'dev',
    repo_branch: str = 'main'
) -> None:# and trigger the child (sub) flows
    """_summary_

    Args:
        files_list (list[str]): _description_
        working_dir (Path): _description_
        overwrite: _description_
        qvf_stages_out: _description_
        env_state (str, optional): _description_. Defaults to 'dev'.
    """
    logger = get_run_logger()

    <http://logger.info|logger.info>(f"Cloning {GITLAB_REPO}:{repo_branch} to {working_dir}...")
    cred = GitLabCredentials.load(CRED_BLOCK_NAME)
    code_dir = await git_clone(
        GITLAB_REPO, branch=repo_branch, access_token=cred
    )
    <http://logger.info|logger.info>(f"Cloned into {code_dir}")
    
    # ...
I am getting:
Copy code
File "venv/lib/python3.12/site-packages/prefect/deployments/steps/pull.py", line 47, in _pull_git_repository_with_retries
    await repo.pull_code()
  File ".venv/lib/python3.12/site-packages/prefect/runner/storage.py", line 417, in pull_code
    await self._clone_repo()
  File ".venv/lib/python3.12/site-packages/prefect/runner/storage.py", line 425, in _clone_repo
    repository_url = self._repository_url_with_credentials
                     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File ".venv/lib/python3.12/site-packages/prefect/runner/storage.py", line 218, in _repository_url_with_credentials
    else deepcopy(self._credentials)
         ^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "~/.local/share/uv/python/cpython-3.12.11-linux-x86_64-gnu/lib/python3.12/copy.py", line 136, in deepcopy
    y = copier(x, memo)
        ^^^^^^^^^^^^^^^
  File "~/.local/share/uv/python/cpython-3.12.11-linux-x86_64-gnu/lib/python3.12/copy.py", line 221, in _deepcopy_dict
    y[deepcopy(key, memo)] = deepcopy(value, memo)
                             ^^^^^^^^^^^^^^^^^^^^^
  File "~/.local/share/uv/python/cpython-3.12.11-linux-x86_64-gnu/lib/python3.12/copy.py", line 151, in deepcopy
    rv = reductor(4)
         ^^^^^^^^^^^
TypeError: cannot pickle 'coroutine' object
m
thought for 17.2 seconds
git_clone
isn’t expecting a loaded block object there. You’re doing:
Copy code
python
cred = GitLabCredentials.load(CRED_BLOCK_NAME)
code_dir = await git_clone(
    GITLAB_REPO, branch=repo_branch, access_token=cred
)
and the error ends in:
Copy code
python
TypeError: cannot pickle 'coroutine' object
That strongly suggests the credentials object being passed contains something non-serializable / async-backed for this code path. ## The immediate issue
GitLabCredentials.load(...)
is likely being used incorrectly in this async context / for this function signature. Also, the deployment pull step API is really intended for deployment step configuration, not as a general “clone helper” inside flow code. So this is one of those cases where: - the function is importable - but using it directly in flow code is awkward and not the intended pattern --- # Why this happens
prefect.deployments.steps.git_clone
is designed as a deployment step abstraction. In YAML, this works:
Copy code
yaml
pull:
  - prefect.deployments.steps.git_clone:
      repository: ...
      branch: ...
      credentials: "{{ prefect.blocks.gitlab-credentials.my-token }}"
because Prefect resolves that step configuration in a deployment-step context. But when you call it manually in Python, you need to match the exact runtime expectations of that function, including how credentials are represented. Passing the loaded
GitLabCredentials
block object directly is not matching what this code path expects. --- # Most likely fix If you want to call
git_clone
directly in Python, try passing a plain credentials mapping rather than the block object. But honestly, I would not recommend using
prefect.deployments.steps.pull.git_clone
as your in-flow clone utility. ## Better option Use a normal Git operation from Python: -
subprocess.run(["git", "clone", ...])
- or a dedicated Git library That gives you: - direct control - predictable behavior - no deployment-step abstraction mismatch --- # Why I recommend against
prefect.deployments.steps.git_clone
in flow code Because it is built for deployment pull-step execution, not really for: - ad hoc runtime cloning inside a flow - branch-per-master-run orchestration - returning a clone path for later use in child runs For your use case, a plain helper is a better fit. --- # Recommended replacement Use a normal helper like this:
Copy code
python
from pathlib import Path
import subprocess
import uuid


def clone_repo(repository: str, branch: str, base_dir: Path) -> Path:
    clone_dir = base_dir / f"repo-{uuid.uuid4().hex}"

    subprocess.run(
        [
            "git",
            "clone",
            "--branch",
            branch,
            repository,
            str(clone_dir),
        ],
        check=True,
    )

    return clone_dir
Then in your flow:
Copy code
python
<http://logger.info|logger.info>("Cloning %s:%s ...", GITLAB_REPO, repo_branch)
code_dir = clone_repo(GITLAB_REPO, repo_branch, Path("/my_scratch/prefect-code"))
<http://logger.info|logger.info>("Cloned into %s", code_dir)
--- # If the repo needs authentication Then you have a few choices: ## Option 1: rely on existing Git credentials on the worker host For example: - credential helper - deploy token - SSH key - already-authenticated environment ## Option 2: inject token into the clone URL If using HTTPS, something like:
Copy code
python
def build_gitlab_url_with_token(repo_url: str, token: str) -> str:
    return repo_url.replace("https://", f"<https://oauth2:{token}@>")
Then:
Copy code
python
token = GitLabCredentials.load(CRED_BLOCK_NAME).token.get_secret_value()
repo_url = build_gitlab_url_with_token(GITLAB_REPO, token)
and clone that URL. I’d be careful with logging to avoid leaking the token. --- # One more thing:
load()
may itself need awaiting depending on context Your code has:
Copy code
python
cred = GitLabCredentials.load(CRED_BLOCK_NAME)
If
load()
is async in your environment/integration, then
cred
may itself be a coroutine object unless awaited. That would very neatly explain:
Copy code
python
TypeError: cannot pickle 'coroutine' object
because then you are passing a coroutine instead of a resolved credentials object. So at minimum, if you continue testing this path, check whether this should be:
Copy code
python
cred = await GitLabCredentials.load(CRED_BLOCK_NAME)
But even if that fixes this specific error, I still would not recommend this deployment-step function as your runtime clone mechanism. --- # So the answer is probably twofold ## 1. Immediate bug You may be passing either: - the wrong object type to
git_clone
- or an un-awaited coroutine from
GitLabCredentials.load(...)
## 2. Bigger design issue
prefect.deployments.steps.pull.git_clone
is the wrong abstraction for cloning inside your flow code. --- # My recommendation For your master-run “clone once, fan out child runs” pattern: - use a normal Git clone helper in Python - optionally get the token from
GitLabCredentials
- pass the resulting clone dir into child
job_variables.working_dir
That is cleaner and more predictable. --- # If you want to keep using the Prefect GitLab block only for secrets That’s a nice compromise. Example:
Copy code
python
from pathlib import Path
import subprocess
import uuid

from prefect_gitlab import GitLabCredentials


def clone_repo_with_token(repo_url: str, branch: str, token: str, base_dir: Path) -> Path:
    clone_dir = base_dir / f"repo-{uuid.uuid4().hex}"
    authed_url = repo_url.replace("https://", f"<https://oauth2:{token}@>")

    subprocess.run(
        [
            "git",
            "clone",
            "--branch",
            branch,
            authed_url,
            str(clone_dir),
        ],
        check=True,
    )

    return clone_dir
Inside your flow:
Copy code
python
cred = await GitLabCredentials.load(CRED_BLOCK_NAME)
token = cred.token.get_secret_value()

code_dir = clone_repo_with_token(
    repo_url=GITLAB_REPO,
    branch=repo_branch,
    token=token,
    base_dir=Path("/my_scratch/prefect-code"),
)
Again, don’t log the authenticated URL. --- # Short answer This is not working because: -
prefect.deployments.steps.pull.git_clone
is a deployment-step utility, not a great runtime clone helper - and you are likely passing an unresolved coroutine or incompatible credentials object into it Useful docs: - Prefect deployment docs: deployments docs - Prefect GitLab integration: prefect-gitlab If you want, I can write a production-safe
clone_repo
helper
for your master flow that: - uses
GitLabCredentials
- supports branch selection - avoids logging secrets - and cleans up the clone afterward.
s
@Marvin Back to a single
yaml
(for both master.py and child s2_satproc_shared_dev deployments) design with gitclone step:
Copy code
yaml
name: overnight_prefect
prefect-version: 3.6.22
build: []
push: []
pull:
  - prefect.deployments.steps.git_clone:
      repository: <https://gitlab.com/my_project.git>
      branch: my-feat-branch
      credentials: "{{ prefect.blocks.gitlab-credentials.my-token }}"

deployments:
  - name: s2_satproc_shared_dev
    description: "Creating sentinel2 QVF downstream products - shared cluster deployment."
    entrypoint: flows/child_flow.py:sen2_satproc_shared
    parameters:
      import_file: "test.img"
      working_dir: "/my_scratch/dir"
      overwrite: false
    work_pool:
      name: athena_dev
      job_variables:
        working_dir: /tmp/default-placeholder
        env:
          RSS_DEFAULT_FILEMODE: "644"
          PREFECT_PYTHON_CMD: "x/x/xx/python"
          SATPROC_SHARED_DIR: "{{ prefect.variables.satproc_workdir_dev }}"
          DASK_SCHEDULER_ADDRESS: null
          GIT_TERMINAL_PROMPT: "0"
Can't I just make the multiple clones of my code repo dynamic (non-conflicting) by simply overriding the value of the
working_dir
setting programatically from my master.py logic:
Copy code
for f_process in to_process:
        <http://logger.info|logger.info>(f"submitting processing for {f_process.infile}")
        # we run several child flows, getting their results concurrently
        main_params = {
            "import_file": f_process.infile,
            "working_dir": working_dir,
            "overwrite": overwrite,
        }

        flow_run = await run_deployment(  # type: ignore[misc]
            name=f"sen2-satproc-shared/s2_satproc_shared_{env_state}",
            parameters=main_params,
            timeout=0,
            job_variables={
                "working_dir": working_dir / flowrun.id,
                "env": {
                    "DASK_SCHEDULER_ADDRESS": scheduler_address,
                    }
                }
        )
Will that override mean that my child deployment clones go into the directory specified by the dynamic value of
working_dir
setting. I think this is the default behaviour when using a procces worker, the worker runs the deployment git_clone step using the work pools
working_dir
value.
m
thought for 19.0 seconds
Yes — that is a reasonable idea, and in a process-worker setup it may be enough if the worker’s
working_dir
is the directory the pull step clones into for that run. So conceptually, what you want is: - keep
git_clone
in
prefect.yaml
- for each child run, override
job_variables.working_dir
- give each child run a unique directory - let the pull step clone into that unique run directory - avoid conflicts That is the right line of thinking. ## Short answer Yes, if your process work pool uses
working_dir
as the run’s execution directory for pull steps, then overriding
job_variables.working_dir
per
run_deployment(...)
should give each child run its own clone location and avoid collisions.
But there are two important caveats. --- # Caveat 1: use the submitted child flow run id, not
flowrun.id
before it exists In your example:
Copy code
python
job_variables={
    "working_dir": working_dir / flowrun.id,
}
that won’t work as written because
flowrun.id
is only known after
run_deployment(...)
returns. So you need to generate your own unique directory name before submission, for example with: -
uuid.uuid4()
- the infile basename - timestamp - parent/master flow run id Example:
Copy code
python
import uuid

clone_dir = working_dir / f"child-run-{uuid.uuid4().hex}"
Then:
Copy code
python
flow_run = await run_deployment(
    ...,
    job_variables={
        "working_dir": str(clone_dir),
        "env": {
            "DASK_SCHEDULER_ADDRESS": scheduler_address,
        },
    },
)
--- # Caveat 2:
working_dir
must be the directory used by the process worker before the pull step runs This is the key assumption. You said:
I think this is the default behaviour when using a process worker, the worker runs the deployment git_clone step using the work pool’s
working_dir
value.
That is plausible, and it’s the behavior your design depends on. If that is how your process pool template is operating, then yes, the override should work as intended. In practice, I would treat this as: - very likely correct for your setup - but worth validating with one tiny test deployment first --- # Recommended implementation ## YAML Your YAML can stay structurally like this:
Copy code
yaml
name: overnight_prefect
prefect-version: 3.6.22
build: []
push: []
pull:
  - prefect.deployments.steps.git_clone:
      repository: <https://gitlab.com/my_project.git>
      branch: my-feat-branch
      credentials: "{{ prefect.blocks.gitlab-credentials.my-token }}"

deployments:
  - name: s2_satproc_shared_dev
    description: "Creating sentinel2 QVF downstream products - shared cluster deployment."
    entrypoint: flows/child_flow.py:sen2_satproc_shared
    parameters:
      import_file: "test.img"
      working_dir: "/my_scratch/dir"
      overwrite: false
    work_pool:
      name: athena_dev
      job_variables:
        working_dir: /tmp/default-placeholder
        env:
          RSS_DEFAULT_FILEMODE: "644"
          PREFECT_PYTHON_CMD: "x/x/xx/python"
          SATPROC_SHARED_DIR: "{{ prefect.variables.satproc_workdir_dev }}"
          DASK_SCHEDULER_ADDRESS: null
          GIT_TERMINAL_PROMPT: "0"
The placeholder
working_dir
is fine if you always override it at runtime. --- ## Master flow override Something like this: ```python from pathlib import Path import uuid for f_process in to_process: logger.info("submitting processing for %s", f_process.infile) main_params = { "import_file": f_process.infile, "working_dir": str(working_dir), "overwrite": overwrite, } clone_dir = working_dir / "prefect-clones" / f"child-run-{uuid.uuid4().hex}" clone_dir_str = str(clone_dir) flow_run = await run_deployment( name=f"sen2-satproc-shared/s2_satproc_shared_{env_state}", parameters=main_params, timeout=0, job_variables={ "working_dir": clone_dir_str, "env": { "DASK_SCHEDULER_ADDRESS": scheduler_address,
}, }, ) logger.info( "submitted flow run %s - %s for %s using clone dir %s", flow_run.name, flow_run.id, f_process.infile, clone_dir_str, )
Copy code
---

# What I would expect this to do

For each child flow run:

1. process worker receives the run
2. worker uses that run’s `job_variables.working_dir`
3. pull step executes in that isolated working directory
4. `git_clone` clones repo there
5. child run uses that isolated checkout
6. no clone race with other child runs

That is the desired behavior.

---

# Why this is better than your earlier shared-dir design

Because before, all child runs were effectively competing for the same clone location.

Now each run gets:
- its own working directory
- its own code checkout
- no shared mutation of the same filesystem path

That is the correct concurrency fix.

---

# Two practical concerns

## 1. Cleanup
If each child run clones into a unique directory, these directories will accumulate.

You’ll probably want:
- a cleanup task
- retention cleanup cron/job
- or a parent/master cleanup after child completion if feasible

For example, structure them under:

```text
/my_scratch/dir/prefect-clones/
so cleanup is easy. ## 2. Cost Cloning the repo for every child run may still be expensive. It solves the race, but not the clone overhead. If you launch many children, this can create: - lots of Git traffic - storage churn - startup latency So: - correctness-wise: good fix - performance-wise: maybe acceptable, maybe not --- # About branch flexibility This also keeps your nice feature-branch behavior. You can still set:
Copy code
yaml
pull:
  - prefect.deployments.steps.git_clone:
      repository: ...
      branch: my-feat-branch
or deploy different YAMLs/branches as needed. If you later want branch to vary per deployment set, you’d likely need separate deployment definitions or regenerate the deployment config. But for clone-race avoidance alone, unique
working_dir
per run is enough. --- # My recommendation Yes — this is probably the cleanest thing to try next without abandoning
git_clone
. ## I would do exactly this: - keep a single YAML - keep
git_clone
- override
job_variables.working_dir
uniquely per child run - use
str(...)
for the path - log the assigned clone dir - verify in one or two child runs that each gets a distinct checkout --- # Small correction to your example Use a unique dir you generate before submission:
Copy code
python
import uuid

clone_dir = working_dir / "prefect-clones" / f"{uuid.uuid4().hex}"
not
flowrun.id
before
flow_run
exists. --- # Final answer Yes, overriding
job_variables.working_dir
programmatically per child
run_deployment(...)
is a sensible way to make
git_clone
land in distinct per-run directories and avoid clone conflicts on a process worker — assuming your process worker uses that
working_dir
for the pull step, which is likely in your setup.
If you want, I can help you write: - a safe unique-directory helper - and a cleanup strategy for those per-run clone directories.
s
@Marvin Perfect, overriding
job_variables.working_dir
works as expected. Another thing I would like to improve is observability in my Dask dashboard. Right mow this is the guts of the design of the
child.py
code that does all the processing for a given child flow run:
Copy code
# This is a plain Python function containing all Prefect tasks and executes .submit() on each of them
def run_sfcref_tasks(
    qvf_stage: str,
    sen2_files: Sen2ProcessFiles,
    sat_proc_tasks: SatProcTasks,
    angles: PrefectFuture[str],
    **kwargs: object
) -> PrefectFuture[str]:

    # Prefect task are executed via
    # the sat_proc_tasks object that holds all Prefect task definitions 
    dem: PrefectFuture[str] = sat_proc_tasks.run_dem.submit(
            templatefile=toa_infile,
            outfile=sen2_proc.dem,
            **kwargs
        )
    # ...


# This is a plain Python function containing all the flow run business logic
def sen2_satproc(
    import_file: str,
    working_dir: Path | None = None,
    overwrite: bool = False,
    # scheduler_address: str | None = None
) -> list[str]:

    # ...
    # calls run_sfcref_tasks()

# This is the wrapper Flow function for 'standalone' deployment.
# Temp cluster is statically created
@flow(
    task_runner=DaskTaskRunner(
        cluster_class=PBSCluster,  # type: ignore[arg-type]
        cluster_kwargs=pbs_cluster_kwargs,
        adapt_kwargs={"maximum": MAX_WORKERS},
    ),
    log_prints=False,
    persist_result=False,
    retries=1,
    retry_delay_seconds=20,
)
def sen2_satproc_standalone(
    import_file: str,
    working_dir: Optional[Path] = None,
    overwrite: bool = False,
) -> list[str]:

    # ...
    return sen2_satproc(
        import_file=import_file,
        working_dir=working_dir,
        overwrite=overwrite
    )
So, with this design I am losing task observability in my Dask dashboard, as I can only see a stream of 'run' tasks without any reference to the Prefect tasks being run with
run_sfcref_tasks()
. Is there a a way to improve this design so that I can regain Prefect task observability in my Dask dashboard?
m
thought for 23.0 seconds
Yes — this behavior makes sense with your current design. ## Why you only see generic
run
tasks Because your Dask dashboard is showing the Dask-level submitted callables, not your plain Python helper structure. In your design: -
sen2_satproc_standalone
is a flow -
sen2_satproc(...)
is a plain Python function -
run_sfcref_tasks(...)
is a plain Python function - inside that function, you call Prefect tasks via
.submit()
So the actual Dask-visible units are the submitted Prefect task wrappers, and depending on how they are wrapped/named, the Dask dashboard may just show generic task names like
run
. The important point is: plain Python helper functions do not become visible orchestration objects in Prefect or richly named Dask tasks. They are just local structure in the flow process. --- # What you can improve ## Best improvement: give your Prefect tasks explicit names If the tasks inside
sat_proc_tasks
are defined without meaningful names, the Dask dashboard can wind up showing generic wrapper labels. For example, instead of relying on default names, define tasks like:
Copy code
python
from prefect import task

@task(name="run-dem")
def run_dem(...):
    ...
or, if these are methods/wrapped task objects, ensure the underlying task definitions have useful
name=
values. That is the first thing I’d improve. --- # Why helper functions don’t show up These functions:
Copy code
python
def run_sfcref_tasks(...): ...
def sen2_satproc(...): ...
are not Prefect tasks or flows. So they won’t appear as distinct units in: - Prefect UI task graphs - Dask task stream labels They’re just Python code that submits actual Prefect tasks. That is fine architecturally, but it means observability comes from the submitted task definitions themselves. --- # Recommended design improvements ## Option 1 — Keep plain Python helpers, but make each submitted Prefect task well named This is the least disruptive and likely the best first step. Example:
Copy code
python
@task(name="dem-generation")
def run_dem(...):
    ...
Then in the dashboard you’re more likely to see useful task labels. If
SatProcTasks
is a container object holding tasks, make sure those tasks are declared with explicit names. --- ## Option 2 — Break major phases into subflows If you want more observability at a higher level, turn major business-logic phases into subflows. For example:
Copy code
python
@flow(name="surface-reflectance-phase")
def run_sfcref_tasks(...):
    dem = sat_proc_tasks.run_dem.submit(...)
    ...
    return ...
Then your Prefect UI gets much better structure: - parent child flow - subflow phases - task grouping by subflow However: ### Important caveat Subflows help Prefect observability a lot more than Dask dashboard naming. The Dask dashboard still fundamentally shows worker-executed tasks, not your whole Prefect graph semantics. So this improves orchestration visibility more than raw Dask task stream readability. --- ## Option 3 — Use task run names dynamically For repetitive tasks operating on files/stages, dynamic naming can help in Prefect. For example, if supported in your task structure, use: - meaningful task names - or task run names that include the file/stage Example conceptually:
Copy code
python
@task(name="run-dem")
def run_dem(templatefile: str, outfile: str, **kwargs):
    ...
and log file context in the task. If you have many repeated runs of the same task type, the Dask dashboard may still not show every parameterized detail, but Prefect UI/logs become much more intelligible. --- # What probably will not help much ## Wrapping
run_sfcref_tasks()
itself as a plain function differently As long as it stays plain Python, it won’t appear as a first-class observable unit. ## Expecting Dask dashboard to mirror Prefect’s full task graph Dask dashboard is great for: - worker utilization - task throughput - scheduling pressure - memory/compute behavior
But it is not a perfect semantic mirror of Prefect task names/relationships. So the realistic goal is: - improve task naming - improve subflow structure for Prefect UI - use Dask dashboard mainly for execution/resource-level visibility --- # A better structure Here’s a version I’d recommend. ## Keep business logic helpers if you like, but make major phases subflows and tasks explicit ### Tasks
Copy code
python
from prefect import task

@task(name="run-dem")
def run_dem(templatefile: str, outfile: str, **kwargs) -> str:
    ...

@task(name="run-angles")
def run_angles(...) -> str:
    ...
### Subflow for a phase
Copy code
python
from prefect import flow
from prefect.futures import PrefectFuture

@flow(name="run-sfcref-phase")
def run_sfcref_tasks(
    qvf_stage: str,
    sen2_files: Sen2ProcessFiles,
    sat_proc_tasks: SatProcTasks,
    angles: PrefectFuture[str],
    **kwargs: object,
) -> PrefectFuture[str]:
    dem = sat_proc_tasks.run_dem.submit(
        templatefile=toa_infile,
        outfile=sen2_proc.dem,
        **kwargs,
    )
    ...
    return dem
### Main business logic flow helper
Copy code
python
def sen2_satproc(
    import_file: str,
    working_dir: Path | None = None,
    overwrite: bool = False,
) -> list[str]:
    ...
    result = run_sfcref_tasks(
        qvf_stage=qvf_stage,
        sen2_files=sen2_files,
        sat_proc_tasks=sat_proc_tasks,
        angles=angles,
        **kwargs,
    )
    ...
    return files_saved
### Deployment wrapper
Copy code
python
@flow(
    task_runner=DaskTaskRunner(
        cluster_class=PBSCluster,
        cluster_kwargs=pbs_cluster_kwargs,
        adapt_kwargs={"maximum": MAX_WORKERS},
    ),
    log_prints=False,
    persist_result=False,
    retries=1,
    retry_delay_seconds=20,
)
def sen2_satproc_standalone(
    import_file: str,
    working_dir: Optional[Path] = None,
    overwrite: bool = False,
) -> list[str]:
    return sen2_satproc(
        import_file=import_file,
        working_dir=working_dir,
        overwrite=overwrite,
    )
--- # My strongest practical recommendation If your goal is specifically better observability, do these in order: ## 1. Ensure every Prefect task has an explicit, meaningful
name=...
This is the highest ROI change. ## 2. Convert major logical phases like
run_sfcref_tasks
into subflows This improves Prefect UI structure significantly. ## 3. Keep using plain Python helpers for low-level organization if you want That is fine; just don’t expect them to show up directly in Dask. --- # Important realism check Even after improvements, the Dask dashboard may still not be as semantically rich as you want. For human-readable orchestration observability: - Prefect UI is the better source For execution/resource observability: - Dask dashboard is the better source Trying to make the Dask dashboard fully express Prefect orchestration structure has limits. --- # If you want task names to be especially useful For repeated file-processing tasks, I’d also consider task naming conventions like: -
run-dem
-
run-angles
-
run-toa
-
run-sfcref
and log the file/stage inside each task. That often gives the best balance: - stable task names in dashboards - detailed file-specific context in Prefect logs --- # Bottom line Yes — you can improve observability, but the key is to improve the actual Prefect task definitions and optionally promote major helper phases into subflows. The plain helper functions themselves are not the source of observable task names. If you want, I can help you refactor your sketch into a concrete pattern with: - named tasks - one or two subflows - and guidance on what will show up in Prefect UI vs Dask dashboard.
s
@Marvin Yeah. . . I am already naming my Prefect tasks defined in the
SatProcTasks
. Prefect UI shows DAG dependencies and orchestration nicely with the correct task labels. So, I guess Dask dashboard observability cannot be improved without promoting helper phases/functions as subflows. . . And I don't think that would improve Prefect UI observability; if anything would make it more cumbersome by unnecessarily nesting a flows.
m
thought for 13.5 seconds
Yes — I think your conclusion is basically right. If: - your actual Prefect tasks already have good names - Prefect UI already shows the DAG and labels correctly - and the Dask dashboard still mostly shows generic
run
tasks then the limitation is mostly in the Dask-side representation, not your Prefect task modeling. ## Short version No, there probably isn’t a clean way to make the Dask dashboard show much richer Prefect task semantics in your current design. And yes: promoting helper phases into subflows is more likely to improve Prefect UI structure than Dask dashboard labeling, while also adding nesting you may not want. So I would not recommend subflows purely for Dask dashboard cosmetics. --- # Why this happens Even though Prefect knows your task names and dependencies,
prefect-dask
still submits wrapped work to Dask. The Dask dashboard is reflecting the Dask execution layer, not Prefect’s full orchestration semantics. So the two observability layers are: ## Prefect UI Best for: - task names - DAG dependencies - retries/failures - orchestration structure ## Dask dashboard Best for: - worker utilization - queueing - throughput - task stream / occupancy - cluster performance That mismatch is expected. --- # What I would recommend ## Keep your current structure If the Prefect UI already gives you the observability you need, and the current helper-function organization is clean, I would keep it. ## Use the Dask dashboard mainly for cluster/runtime signals For example: - are workers alive? - are tasks queueing? - are workers saturated? - is there skew or memory pressure? - are tasks actually distributed? not for fine-grained Prefect task naming. --- # Practical takeaway You’ve already achieved the more important form of observability: - Prefect knows the real DAG - Prefect shows the real task labels So I would not distort your flow design just to try to make the Dask dashboard more semantically pretty. That tradeoff usually isn’t worth it. --- # My honest conclusion Yes — in your case, Dask dashboard observability is probably near its natural ceiling. You can still use it very effectively, just for the right things: - scheduler/worker behavior - task backlog - execution throughput - memory and CPU usage and rely on Prefect UI for: - named task orchestration - dependencies - retries/failures - run-level structure That’s a perfectly reasonable split. If you want, I can give you a short “best practices for using Prefect UI + Dask dashboard together” checklist for this exact master/child shared-cluster setup.
s
@Marvin Going back to running my child deployment asynchronously:
Copy code
for f_process in to_process:
            <http://logger.info|logger.info>(f"submitting processing for {f_process.infile}")
            # we run several child flows, getting their results concurrently
            main_params = {
                "import_file": f_process.infile,
                "working_dir": master_dir,
                "overwrite": overwrite,
                # "qvf_stages_out": qvf_stages_out
            }
            # create tmp dir where code will be cloned with correct permissions
            tmp_dir = create_clone_dir(working_dir=master_dir)
            tmp_dirs.append(tmp_dir)

            flow_run = await run_deployment(  # type: ignore[misc]
                name=f"sen2-satproc-shared/s2_satproc_shared_{env_state}",
                parameters=main_params,
                timeout=0,
                job_variables={
                    "working_dir": tmp_dir.name,
                    "env": {
                        "DASK_SCHEDULER_ADDRESS": scheduler_address,
                        }
                    }
            )
            <http://logger.info|logger.info>(f"submitted flow run {flow_run.name} - {flow_run.id} for {f_process.infile}")

            submitted_runs.append(
                {
                    "flow_run_id": flow_run.id,
                    "import_file": f_process.infile
                }
            )

        # wait for all concurrently and collect results as they finish
        files_saved: list[str | object] = []
        failures: list[Exception] = []
        pending = [asyncio.create_task(resolve_flow_run(run_info)) for run_info in submitted_runs]
        for completed in asyncio.as_completed(pending):
            r_d = await completed
            <http://logger.info|logger.info>(
                "Child flow run %s finished for %s.",
                r_d["flow_run_id"], r_d["import_file"]
            )
            result = r_d["result"]
            if not isinstance(result, Exception):
                <http://logger.info|logger.info>(f"files saved: {result=}")
                files_saved.extend(result)  # type: ignore[arg-type]
            else:
                <http://logger.info|logger.info>("%s - %s failed with error:", r_d["import_file"], r_d["flow_run_id"])
                logger.warning(result)
                failures.append(result)
What if need to add running another further asynchronously that depends on the result of my
submitted_runs
list. What is the best approach using
asyncio
?
m
thought for 20.0 seconds
If the next async step depends on each child flow’s result, the best pattern is usually: process each child run result as soon as it completes, and immediately schedule the dependent follow-up coroutine for that result. So instead of: 1. wait for all child runs 2. then start the next async phase you can do a pipeline: - submit all child deployments - as each child finishes, collect its result - immediately launch the downstream async work that depends on that result - optionally wait for all downstream work at the end That is typically the most efficient
asyncio
approach. --- # Recommended pattern Use
asyncio.as_completed(...)
for the first wave, and inside that loop create follow-up tasks. ## Conceptual structure
Copy code
python
submitted child deployments
    ↓
wait for child completion as they finish
    ↓
schedule dependent async follow-up immediately
    ↓
wait for all follow-up tasks
--- # Why this is better Because if child run A finishes early, you don’t have to wait for child runs B/C/D before starting A’s downstream work. That gives you more concurrency and better throughput. --- # Example shape Suppose you have: -
resolve_flow_run(run_info)
→ waits for a child deployment and returns its result -
process_child_result(r_d)
→ another async function that depends on that result Then do:
Copy code
python
pending_children = [asyncio.create_task(resolve_flow_run(run_info)) for run_info in submitted_runs]
pending_followups = []

for completed in asyncio.as_completed(pending_children):
    r_d = await completed

    # schedule downstream async work immediately
    followup_task = asyncio.create_task(process_child_result(r_d))
    pending_followups.append(followup_task)

# after all children have finished, wait for all follow-up tasks
followup_results = await asyncio.gather(*pending_followups, return_exceptions=True)
That is the key pattern. --- # More concrete example ## Step 1: child result resolver You already have this:
Copy code
python
async def resolve_flow_run(run_info: dict) -> dict:
    result = await wait_and_get_flow_run_result(run_info["flow_run_id"])
    return {
        "flow_run_id": run_info["flow_run_id"],
        "import_file": run_info["import_file"],
        "result": result,
    }
## Step 2: dependent async work For example:
Copy code
python
async def process_child_output(r_d: dict) -> dict:
    result = r_d["result"]

    if isinstance(result, Exception):
        return {
            "flow_run_id": r_d["flow_run_id"],
            "import_file": r_d["import_file"],
            "status": "failed-upstream",
            "result": result,
        }

    # Do more async work here
    await asyncio.sleep(0)  # placeholder

    return {
        "flow_run_id": r_d["flow_run_id"],
        "import_file": r_d["import_file"],
        "status": "processed",
        "result": result,
    }
## Step 3: pipeline both stages
Copy code
python
pending_children = [asyncio.create_task(resolve_flow_run(run_info)) for run_info in submitted_runs]
pending_followups = []

files_saved: list[str | object] = []
failures: list[Exception] = []

for completed in asyncio.as_completed(pending_children):
    r_d = await completed

    <http://logger.info|logger.info>(
        "Child flow run %s finished for %s.",
        r_d["flow_run_id"],
        r_d["import_file"],
    )

    result = r_d["result"]
    if not isinstance(result, Exception):
        <http://logger.info|logger.info>("files saved: result=%s", result)
        files_saved.extend(result)
    else:
        <http://logger.info|logger.info>("%s - %s failed with error:", r_d["import_file"], r_d["flow_run_id"])
        logger.warning(result)
        failures.append(result)

    pending_followups.append(asyncio.create_task(process_child_output(r_d)))

followup_results = await asyncio.gather(*pending_followups, return_exceptions=True)
--- # If the second async phase depends on all child results together That is a different case.
If the downstream async function needs the entire collected result set, then do: 1. wait for all child runs 2. aggregate results 3. call one downstream async function Example:
Copy code
python
resolved = []
for completed in asyncio.as_completed(pending_children):
    r_d = await completed
    resolved.append(r_d)

final_result = await run_next_async_stage(resolved)
So the right pattern depends on whether the downstream work is: - per child result → pipeline each as completed - depends on all child results together → aggregate first, then run next step --- # Best practice: use bounded concurrency for downstream follow-up if needed If the downstream async stage is expensive or external-API-heavy, you may not want unlimited parallelism. Then use a semaphore. Example:
Copy code
python
semaphore = asyncio.Semaphore(5)

async def process_child_output_limited(r_d: dict) -> dict:
    async with semaphore:
        return await process_child_output(r_d)
Then:
Copy code
python
pending_followups.append(
    asyncio.create_task(process_child_output_limited(r_d))
)
That’s often the safest production pattern. --- # If the downstream step is itself another Prefect deployment If the “another further async function” is actually another deployment run, you can use the same pattern: - child deployment finishes - as soon as it finishes, call
run_deployment(..., timeout=0)
for the next stage - collect those second-stage run ids - wait for those too That becomes a multi-stage async orchestration pipeline. --- # Recommended decision rule ## Use
as_completed(...)
when: - you want to act on results as soon as each one is ready ## Use
gather(...)
when: - you want to wait for all results before doing the next thing --- # My recommendation for your case From your description, it sounds like the next async work depends on each child result individually. So I’d recommend: keep
asyncio.as_completed(...)
for
submitted_runs
, and inside that loop immediately create downstream async tasks for each resolved child result.
That is the most efficient pattern. --- # Skeleton you can adapt
Copy code
python
pending_children = [asyncio.create_task(resolve_flow_run(run_info)) for run_info in submitted_runs]
pending_next_stage = []

for completed in asyncio.as_completed(pending_children):
    r_d = await completed

    # handle/log child result
    ...

    # launch next async stage for this completed child
    pending_next_stage.append(
        asyncio.create_task(next_async_stage(r_d))
    )

next_stage_results = await asyncio.gather(*pending_next_stage, return_exceptions=True)
--- # One extra suggestion If the code is getting more than 2 async stages deep, it can help to wrap each stage result in a typed structure instead of raw dicts. For example, use a `dataclass`:
Copy code
python
from dataclasses import dataclass
from uuid import UUID

@dataclass
class ChildRunResult:
    flow_run_id: UUID
    import_file: str
    result: object
That makes multi-stage async orchestration much easier to maintain than untyped dicts. --- If you want, I can refactor your exact snippet into: - stage 1: wait for child deployments - stage 2: launch dependent async work - stage 3: aggregate successes/failures using `dataclass`es and clearer typing.
s
@Marvin Thanks, I like your first approach:
Copy code
python
submitted child deployments
    ↓
wait for child completion as they finish
    ↓
schedule dependent async follow-up immediately
    ↓
wait for all follow-up tasks
Now, in this case I am running deployments, for both cases. I have two further questions: 1. I wonder if it is possible to pass a Prefect future when submitting the deployments via
run_deployment()
2. The Prefect futures in 1 are I/O tasks and I wonder if In could run them using a
ThreadPoolTaskRunner
task runner within a flow that has a static
DaskTaskRunner
definition as its task runner. This is the structure that I am thinking of:
Copy code
@task(tags=["download-concurrency-tag"],)
def download_api_items(item: UUID) -> Path:

@flow(
    task_runner=DaskTaskRunner(
        cluster_class=PBSCluster,  # type: ignore[arg-type]
        cluster_kwargs=pbs_cluster_kwargs,  # defined as dictionary
        adapt_kwargs={"maximum": 50},
    ),
    log_prints=False,
    persist_result=False,
    retries=1,
    retry_delay_seconds=20,
)
def my_parent_coordinator_flow():
    # some business logic here that gets response from API
    sorted_ids: list[UUID] = ...
    
    # run the I/O tasks in a temporary threadpool runner
    with ThreadPoolTaskRunner(max_workers=8) as runner:
        download_futures = download_esa_ids.map(sorted_ids)

    submitted_runs = []
	for d_fut in download_futures:
		<http://logger.info|logger.info>(f"submitting processing for {f_process.infile}")
		# we run several child flows, getting their results concurrently
		main_params = {
			"import_file": d_fut,
			"working_dir": master_dir,
			"overwrite": overwrite,
		}
		# create tmp dir where code will be cloned with correct permissions
		tmp_dir = create_clone_dir(working_dir=master_dir)
		tmp_dirs.append(tmp_dir)

		flow_run = await run_deployment(
			name=f"my-child-flow/child_deployu_shared_cluster",
			parameters=main_params,
			timeout=0,
			job_variables={
				"working_dir": tmp_dir.name,
				"env": {
					"DASK_SCHEDULER_ADDRESS": scheduler_address,
					}
				}
		)
		<http://logger.info|logger.info>(f"submitted flow run {flow_run.name} - {flow_run.id} for {f_process.infile}")

		submitted_runs.append(
			{
				"flow_run_id": flow_run.id,
				"import_file": f_process.infile
			}
		)
		
		# Rest of async processing goes here
Is this approach doable with
run_deployment()
?
m
thought for 26.0 seconds
Short answer: 1. No, you should not pass a Prefect future to
run_deployment()
parameters.
2. No, using
with ThreadPoolTaskRunner(...)
inside a flow will not switch that flow’s active task runner for `.submit()`/`.map()` calls.
But your overall goal is doable with a slightly different structure. --- # 1) Can you pass a Prefect future to
run_deployment()
? Not as a good/supported pattern. Deployment parameters should be plain, serializable values, e.g.: -
str
-
int
-
bool
-
list
-
dict
- JSON-like payloads A
PrefectFuture
is an in-process orchestration object, not a deployment parameter payload. So this is the wrong shape:
Copy code
python
main_params = {
    "import_file": d_fut,   # not recommended
}
Instead, resolve the future first, then pass the actual result:
Copy code
python
downloaded_path = d_fut.result()

main_params = {
    "import_file": str(downloaded_path),
    "working_dir": str(master_dir),
    "overwrite": overwrite,
}
## Rule of thumb Pass results, not futures. --- # 2) Can you use
ThreadPoolTaskRunner
temporarily inside a flow whose static runner is
DaskTaskRunner
? Not the way shown. This part will not do what you want:
Copy code
python
with ThreadPoolTaskRunner(max_workers=8) as runner:
    download_futures = download_esa_ids.map(sorted_ids)
Because
download_esa_ids.map(...)
will still use the flow’s active task runner from
FlowRunContext
, which in your flow is the static
DaskTaskRunner
. This is the same issue you ran into earlier with
with DaskTaskRunner(...)
inside a flow: - entering the runner context manages resources - it does not replace the flow’s actual runner for task submission So: -
task.submit()
-
task.map()
still use the flow’s configured runner. --- # What is doable? Yes, your overall pattern is doable, but with one of these designs: ## Option A — Keep one parent flow with
DaskTaskRunner
, and do downloads as plain async/threaded Python outside Prefect tasks Then: - do the I/O prefetch/download stage using normal Python concurrency - pass resolved file paths into
run_deployment(...)
This is often the simplest. ## Option B — Split into separate flows/subflows with different task runners For example: - one download flow with
ThreadPoolTaskRunner
- one coordinator/shared-cluster flow with
DaskTaskRunner
This is the clean Prefect-native way if you want both orchestration styles. ## Option C — Keep the parent on Dask and just run download tasks on Dask too If the I/O tasks are light enough, you may not need a separate threadpool runner at all. --- # Best answer to your exact sketch Your current sketch is not correct as written because of both issues: -
download_futures
are Prefect futures, not deployment parameters -
ThreadPoolTaskRunner
context manager inside the flow will not redirect
.map()
--- # Recommended patterns ## Pattern 1: Plain Python concurrency for the I/O stage, then
run_deployment
If these downloads are just preparatory I/O and you mainly need resolved paths, use Python concurrency directly. For example, in an async parent flow:
Copy code
python
import asyncio
from pathlib import Path
from uuid import UUID

async def download_api_item_async(item: UUID) -> Path:
    # wrap your async I/O logic here
    ...
Then in the flow:
Copy code
python
download_paths = await asyncio.gather(
    *(download_api_item_async(item) for item in sorted_ids)
)
Now
download_paths
are plain `Path`s / strings you can pass to
run_deployment(...)
. This avoids mixing Prefect futures into deployment parameters. ### Good when - downloads are just a precursor stage - you don’t specifically need those downloads as Prefect tasks --- ## Pattern 2: Dedicated download subflow with
ThreadPoolTaskRunner
If you do want those download operations to be Prefect tasks, make them their own flow. Example: ```python from pathlib import Path from uuid import UUID from prefect import flow, task
from prefect.task_runners import ThreadPoolTaskRunner @task(tags=["download-concurrency-tag"]) def download_api_items(item: UUID) -> Path: ... @flow(task_runner=ThreadPoolTaskRunner(max_workers=8)) def download_items_flow(sorted_ids: list[UUID]) -> list[Path]: futures = download_api_items.map(sorted_ids) return [f.result() for f in futures]
Copy code
Then the parent/coordinator flow can call this and get plain results back:

```python
@flow(
    task_runner=DaskTaskRunner(
        cluster_class=PBSCluster,
        cluster_kwargs=pbs_cluster_kwargs,
        adapt_kwargs={"maximum": 50},
    ),
)
async def my_parent_coordinator_flow():
    sorted_ids: list[UUID] = ...

    downloaded_paths = download_items_flow(sorted_ids)

    submitted_runs = []
    for path in downloaded_paths:
        flow_run = await run_deployment(
            name="my-child-flow/child_deploy_shared_cluster",
            parameters={
                "import_file": str(path),
                "working_dir": str(master_dir),
                "overwrite": overwrite,
            },
            timeout=0,
            job_variables={
                "working_dir": str(create_clone_dir(working_dir=master_dir).name),
                "env": {
                    "DASK_SCHEDULER_ADDRESS": scheduler_address,
                },
            },
        )
        submitted_runs.append(flow_run)
This is a supported and clean way to use different task runners in different parts of the system. --- ## Pattern 3: Async download stage + child deployment pipeline Since you said both later stages are deployments, here’s a clean version of the pipeline: ### Stage A Resolve download results first ### Stage B Submit child deployments ### Stage C As child deployments finish, launch dependent deployment(s) That is likely the best overall architecture. --- # Can you overlap downloads and child deployment submission? Yes, but with plain async/Python objects, not Prefect futures as deployment params. For example: 1. launch many downloads concurrently 2. as each download completes, submit a child deployment 3. as each child deployment completes, submit the next dependent deployment That is a nice pipeline. --- # Example architecture with
asyncio.as_completed
## Async download helper
Copy code
python
async def download_api_item_async(item: UUID) -> Path:
    ...
## Submit child deployment after each download
Copy code
python
async def submit_child_for_download(path: Path) -> dict:
    flow_run = await run_deployment(
        name="my-child-flow/child_deploy_shared_cluster",
        parameters={
            "import_file": str(path),
            "working_dir": str(master_dir),
            "overwrite": overwrite,
        },
        timeout=0,
        job_variables={
            "working_dir": str(create_clone_dir(working_dir=master_dir).name),
            "env": {"DASK_SCHEDULER_ADDRESS": scheduler_address},
        },
    )
    return {"flow_run_id": flow_run.id, "import_file": str(path)}
## Parent flow sketch
Copy code
python
download_tasks = [asyncio.create_task(download_api_item_async(item)) for item in sorted_ids]

submitted_runs = []

for completed_download in asyncio.as_completed(download_tasks):
    path = await completed_download
    submitted = await submit_child_for_download(path)
    submitted_runs.append(submitted)
Then you can do the same thing again: - wait for child runs as they complete - launch downstream deployment(s) This is probably closer to your desired throughput pattern than trying to pass Prefect futures around. --- # Important note about
ThreadPoolTaskRunner
If you truly want Prefect-managed threadpool execution, use it on a separate flow/subflow, e.g.:
Copy code
python
@flow(task_runner=ThreadPoolTaskRunner(max_workers=8))
def download_flow(...):
    ...
Do not expect this to work inside another running flow:
Copy code
python
with ThreadPoolTaskRunner(...) as runner:
    my_task.map(...)
That will not switch the active task runner.
--- # Final answer ## 1. Passing Prefect futures to
run_deployment()
No — pass the resolved result, not the future. ## 2. Using
ThreadPoolTaskRunner
inside a flow already using
DaskTaskRunner
No — not by context-managing it inside the flow body. ## 3. Is your overall approach doable? Yes, with one of these better patterns: - use plain async/Python concurrency for the I/O stage, then pass resolved values to
run_deployment
- or put the I/O tasks in a separate flow that uses
ThreadPoolTaskRunner
- then submit deployments using the resolved outputs --- # My recommendation for your case Given your architecture, I would do this: 1. Download stage: separate small flow with
ThreadPoolTaskRunner(max_workers=8)
2. Coordinator stage: parent flow with
DaskTaskRunner
3. Child processing stage: child deployments on the shared Dask cluster 4. Downstream dependent deployments: launch them as each child finishes That keeps each concurrency model in a clean place. If you want, I can write a full concrete example of this 3-stage pattern: -
download_flow
with
ThreadPoolTaskRunner
- parent coordinator with
DaskTaskRunner
- child deployment submission + follow-up deployment pipeline with
asyncio.as_completed(...)