Santiago Gutierrez
07/03/2026, 2:23 AM@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
):
...Marvin
07/03/2026, 2:23 AMMarvin
07/03/2026, 2:25 AMDaskTaskRunner 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:
@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:
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},
)Marvin
07/03/2026, 2:25 AMThat 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/indexSantiago Gutierrez
07/03/2026, 2:32 AMMarvin
07/03/2026, 2:32 AMMarvin
07/03/2026, 2:34 AMDaskTaskRunner.
The cleanest options are:
## Option 1: Read it from the flow context
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:
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:
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.Santiago Gutierrez
07/05/2026, 11:43 PMadapt_kwargs={"minimum": 1, "maximum": MAX_WORKERS}Marvin
07/05/2026, 11:43 PMMarvin
07/05/2026, 11:43 PMadapt_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:
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
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.Santiago Gutierrez
07/05/2026, 11:46 PMSure, please expand on this.
Also, show me an example on how best to manage flow/cluster lifetime carefullyMarvin
07/05/2026, 11:46 PMMarvin
07/05/2026, 11:47 PMminimum=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:
python
@flow(task_runner=DaskTaskRunner(cluster_class=PBSCluster, ...))
def master_flow():
...
prefer:
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:
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
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):Marvin
07/05/2026, 11:47 PM---
# 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:
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.Santiago Gutierrez
07/05/2026, 11:59 PMpython
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?Marvin
07/05/2026, 11:59 PMMarvin
07/05/2026, 11:59 PMwith runner: in the child flow with
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:
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.:
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 clusterSantiago Gutierrez
07/06/2026, 12:38 AMMarvin
07/06/2026, 12:38 AMMarvin
07/06/2026, 12:38 AMPBSCluster
- 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
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(Marvin
07/06/2026, 12:38 AM---
# 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:
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:
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:
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:
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
---Marvin
07/06/2026, 12:38 AMprint
- and parameter typing/validation cleaned up for direct copy/paste.Santiago Gutierrez
07/06/2026, 11:18 PM- 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.Marvin
07/06/2026, 11:18 PMMarvin
07/06/2026, 11:18 PMpython
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} "Marvin
07/06/2026, 11:18 PM---
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 testingMarvin
07/06/2026, 11:18 PMprefect.yaml
- or the exact prefect deploy commands for these two flows.Santiago Gutierrez
07/07/2026, 4:28 AMpython
@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:
cluster.adapt(minimum=1, maximum=MAX_WORKERS)
This is not behaving the same as done via:
DaskTaskRunner(
cluster_class=PBSCluster, # type: ignore[arg-type]
cluster_kwargs=pbs_cluster_kwargs,
adapt_kwargs={"maximum": MAX_WORKERS}
)
If use:
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:
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.Marvin
07/07/2026, 4:28 AMMarvin
07/07/2026, 4:31 AMPBSCluster
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:
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:
python
cluster.adapt(maximum=MAX_WORKERS)
or explicitly:
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:
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:
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 countsMarvin
07/07/2026, 4:31 AMpython
cluster.adapt(minimum=1, maximum=X)
or even manually scale for debugging:
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
python
cluster.adapt(minimum=1, maximum=MAX_WORKERS)
### Fully manual debug mode
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:
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:
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:
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.Santiago Gutierrez
07/07/2026, 11:16 PMdef 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:
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:
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 addressMarvin
07/07/2026, 11:17 PMMarvin
07/07/2026, 11:19 PMpython
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:
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:
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:
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:
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
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
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
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.
---Marvin
07/07/2026, 11:19 PMchild_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:
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:
python
@flow
def child_flow_logic(...):
futures = [do_work.submit(i) for i in range(10)]
return [f.result() for f in futures]
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:
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
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
python
configured_flow = child_flow.with_options(task_runner=DaskTaskRunner(...))
configured_flow(...)
or a static decorator:
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.Santiago Gutierrez
07/08/2026, 12:04 AM### 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.Marvin
07/08/2026, 12:04 AMMarvin
07/08/2026, 12:05 AMprefect-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.Marvin
07/08/2026, 12:05 AM---
# 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:
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
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,Marvin
07/08/2026, 12:05 AMThis 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:
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,
}
},Marvin
07/08/2026, 12:05 AM---
# 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.Santiago Gutierrez
07/08/2026, 12:41 AMBecause 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
@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.
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.pyMarvin
07/08/2026, 12:41 AMMarvin
07/08/2026, 12:42 AMpython
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:
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
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",Marvin
07/08/2026, 12:42 AMmaster_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,Marvin
07/08/2026, 12:42 AM---
# 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:
python
scheduler_address: str
as a normal Prefect flow parameter.
Then it creates:
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:
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.Santiago Gutierrez
07/08/2026, 12:54 AMprefect.yaml example for these entrypoints.Marvin
07/08/2026, 12:54 AMMarvin
07/08/2026, 12:54 AMprefect.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
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:
python
child_deployment_name: str = "child-flow-shared/child-shared-deployment"
So in prefect.yaml, I set:
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:
yaml
scheduler_address: ""
because the master flow should pass it at runtime via:
python
run_deployment(
...,
parameters={
"scheduler_address": scheduler_address,
},
)
## 3. working_dir
I left this as:
yaml
working_dir: null
which is usually the safest default in deployment parameters.
---
# How to deploy from this file
From the project directory:
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:
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: ""Marvin
07/08/2026, 12:54 AM---
# 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.Santiago Gutierrez
07/13/2026, 1:02 AMmaster_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:
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:
@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.Marvin
07/13/2026, 1:02 AMMarvin
07/13/2026, 1:04 AMtimeout=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:
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
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:Marvin
07/13/2026, 1:04 AM---
# 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:
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:
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:
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:
python
persist_result=False
on the child flow.
Instead, use either the default behavior or explicitly:
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(Marvin
07/13/2026, 1:04 AMIf 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.Santiago Gutierrez
07/14/2026, 12:03 AMwhile True: control flow actually does here:
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)Marvin
07/14/2026, 12:03 AMMarvin
07/14/2026, 12:03 AMwhile 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
python
while True:
means “loop forever until something inside explicitly exits the loop.”
In your function, the exits are:
### Success exit
python
return
### Failure exit
python
raise RuntimeError(...)
### Timeout exit
python
raise TimeoutError(...)
If none of those happen, it keeps polling.
---
# Step-by-step behavior
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
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
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
python
if state.type != StateType.COMPLETED:
raise RuntimeError(...)
return
## 3. If not finished, check timeout
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
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:
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.:
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:
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.Santiago Gutierrez
07/14/2026, 3:01 AM<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 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:
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
)Marvin
07/14/2026, 3:01 AM<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 workingMarvin
07/14/2026, 3:01 AMSantiago Gutierrez
07/14/2026, 3:06 AM<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 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.Marvin
07/14/2026, 3:06 AMMarvin
07/14/2026, 3:06 AMasyncio.as_completed(...).
The more important problem is this line at the bottom:
python
if __name__ == "__main__":
asyncio.run(s2_satproc_master())
For a Prefect flow, especially one declared as:
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:
python
asyncio.run(s2_satproc_master())
can interfere with Prefect’s runtime/engine behavior.
Instead, just call the flow directly:
python
if __name__ == "__main__":
s2_satproc_master(...)
not:
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:
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:
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:
python
if __name__ == "__main__":
asyncio.run(s2_satproc_master())
with something like:
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`:
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:
python
main_params = {
"import_file": f_process.infile,
"working_dir": str(working_dir),
"overwrite": overwrite,
"scheduler_address": scheduler_address,
}
Then:
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:
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.
---Marvin
07/14/2026, 3:06 AMtask_runner.client may initialize the cluster, I would make this explicit.
Add a tiny task and submit it first.
## Example
python
from prefect import task
@task
def warmup() -> str:
return "ok"
Then in the master flow:
python
warmup.submit().result()
before:
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)Marvin
07/14/2026, 3:06 AMasyncio.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.Santiago Gutierrez
07/14/2026, 3:15 AMmaster.py flow code locally without asyncio.run(s2_satproc_master()) , I get:
master_flow.py:354: RuntimeWarning: coroutine 'run_flow_async' was never awaited
This is my new child deployment 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
)Marvin
07/14/2026, 3:15 AMMarvin
07/14/2026, 3:16 AMasyncio.run(...).
So let me correct the earlier guidance:
- If s2_satproc_master is async def, then in a plain local script, this is normal:
python
if __name__ == "__main__":
asyncio.run(
s2_satproc_master(
files_list=[...],
working_dir=Path("/tmp"),
)
)
The warning you saw:
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:
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:
python
SHARED_SCHEDULER_ADDRESS = os.getenv("DASK_SCHEDULER_ADDRESS")
runs at module import time, not at flow run time.
And this decorator:
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:
python
SHARED_SCHEDULER_ADDRESS
may be None or stale at decoration time.
Then the flow is effectively defined with:
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:
python
@flow(
task_runner=DaskTaskRunner(address=SHARED_SCHEDULER_ADDRESS),
)
def sen2_satproc_shared(...):
...
return child_flow(...)
and you said:
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
python
def child_flow(...):
# plain python business logic
futures = [my_task.submit(...)]
## Different pattern
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:
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,Marvin
07/14/2026, 3:16 AMThis 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
python
@flow
def sen2_satproc_shared_entrypoint(import_file: str, scheduler_address: str, ...):
## Then it constructs the real configured flow
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:
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 designSantiago Gutierrez
07/14/2026, 3:26 AMflow_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:
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.pyMarvin
07/14/2026, 3:26 AMMarvin
07/14/2026, 3:26 AMpython
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:
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:
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:
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:
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 worklooks 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 availabilityMarvin
07/14/2026, 3:26 AM.submit()
The heavy compute should be in tasks, not inline in the flow function.
## 2. Add a diagnostic task
Example:
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:
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.Santiago Gutierrez
07/14/2026, 3:47 AMdebug_cluster() to my child shared deployment flow:
@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:
diag = debug_cluster.submit().result()
Nothing happens, no cluster is added to my PBS scheduler and logs stop just before this point:
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:
@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)Marvin
07/14/2026, 3:47 AMMarvin
07/14/2026, 3:47 AMdebug_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:
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:
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:
python
Preparing shared child flow using external Dask scheduler at tcp://...
- then hang on:
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:
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:
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()Marvin
07/14/2026, 3:47 AMdebug_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:
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:
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:
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:
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.
---Marvin
07/14/2026, 3:47 AMasyncio.gather in chunks, or
2. a debug version of the child flow that distinguishes:
- scheduler connectivity
- worker availability
- actual task execution failureSantiago Gutierrez
07/14/2026, 3:59 AMpython
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:
AttributeError: 'NoneType' object has no attribute 'scheduler_info'Marvin
07/14/2026, 4:00 AMMarvin
07/14/2026, 4:00 AMget_dask_client() is not returning a usable Dask client in that point of execution, so this:
python
with get_dask_client() as client:
is effectively yielding None, and then:
python
client.scheduler_info()
fails.
## What that strongly suggests
In your child flow, despite:
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:
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:
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
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(Marvin
07/14/2026, 4:00 AM---
# 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:
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.Santiago Gutierrez
07/14/2026, 5:00 AMmaster.py and I can confirm that nothing happens: a dask cluster seems to be created but no workers assigned to the cluster:
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?Marvin
07/14/2026, 5:00 AMMarvin
07/14/2026, 5:02 AMasync 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:
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:
python
@flow(...)
async def s2_satproc_master(...):
is fine.
And having normal tasks like:
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:
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:
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:
python
@flow(...)
def my_flow():
expensive_python_function()
That runs in the flow process, not on Dask workers.
Whereas:
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 executionMarvin
07/14/2026, 5:02 AMSantiago Gutierrez
07/15/2026, 2:47 AMDaskTaskRunner 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.Marvin
07/15/2026, 2:48 AMMarvin
07/15/2026, 2:53 AM.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`:
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:
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:
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:
bash
timeout 5 /bin/bash -i -c 'echo OK'
and also:
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:
bash
[[ $- == *i* ]] || return
---
## D. Check temp directories
For that Unix user, inspect:
bash
echo $TMPDIR
ls -ld /tmp
ls -ld "$HOME"
And verify writable temp/work space:
bash
touch /tmp/test_file.$$ && rm /tmp/test_file.$$
mkdir -p "$HOME/tmp_test_dir" && rmdir "$HOME/tmp_test_dir"Marvin
07/15/2026, 2:53 AMPBSCluster 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:
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:
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
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
bash
python -c "import tempfile; print(tempfile.gettempdir())"
touch /tmp/pbs_test.$$ && rm /tmp/pbs_test.$$
## PBS sanity
bash
qstat -u $USER
## Compare environment with a working user
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:
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.Santiago Gutierrez
07/15/2026, 3:40 AMProgress -- total: 15, waiting: 12, queued:2, processing: 0, in-memory:0, no-worker: 1, erred: 0Marvin
07/15/2026, 3:40 AMMarvin
07/15/2026, 3:40 AMprocessing: 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:
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:
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 deniedMarvin
07/15/2026, 3:40 AMNo 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:
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:
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.Santiago Gutierrez
07/16/2026, 1:49 AMDaskTaskRunner 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:
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:
@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
}
)Marvin
07/16/2026, 1:50 AMMarvin
07/16/2026, 1:50 AMDaskTaskRunner behavior you were seeing was just a downstream symptom.
Now for the new issue:
## What’s happening
Your deployment uses the pull step:
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:
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
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 thisMarvin
07/16/2026, 1:50 AMyaml
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:
yaml
work_pool:
name: athena_dev
job_variables:
working_dir: /my_scratch/dir/my_project_checkout
Then the entrypoint:
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:
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:
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
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.Santiago Gutierrez
07/16/2026, 2:32 AMIf 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:
[
{
"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?Marvin
07/16/2026, 2:32 AMMarvin
07/16/2026, 2:32 AMgit_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:
text
/my_scratch/prefect-code/
satproc-main/
satproc-my-feat-branch/
Each is a normal Git checkout.
For example:
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:
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"Marvin
07/16/2026, 2:32 AM## 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
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:
havingThat’s consistent with Prefect needing some code location context. So the practical fix is: - don’t rely on the shell’s current directory - explicitly seteffectively defaults topull: []set_working_directory
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:
text
/my_scratch/prefect-code/
satproc-main/
satproc-dev/
satproc-my-feat-branch/
Then switch deployments by changing:
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:
text
/my_scratch/prefect-code/current -> /my_scratch/prefect-code/satproc-my-feat-branch
Then deployments always use:
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:
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:
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:
python
await run_deployment(
name=f"sen2-satproc-shared/s2_satproc_shared_{env_state}",
...
)
you want:Marvin
07/16/2026, 2:32 AMworking_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
text
/my_scratch/prefect-code/satproc-my-feat-branch/
prefect.yaml
flows/
master_flow.py
child_flow.py
## Deployments
Both master and child use:
yaml
job_variables:
working_dir: /my_scratch/prefect-code/satproc-my-feat-branch
## Updates to feature branch
You update that checkout explicitly:
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.:
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
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:
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.Santiago Gutierrez
07/16/2026, 3:58 AMmaster.py something like:
@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:
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'Marvin
07/16/2026, 3:58 AMMarvin
07/16/2026, 3:58 AMgit_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:
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:
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:
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
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:
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(Marvin
07/16/2026, 3:58 AMThat 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_linkMarvin
07/16/2026, 3:58 AM---
# 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 aboutWith this pattern: -is the ability to grab code from a feature branch. How can I achieve something similar?git_clone
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.Santiago Gutierrez
07/16/2026, 5:44 AMfrom 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:
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' objectMarvin
07/16/2026, 5:44 AMMarvin
07/16/2026, 5:45 AMgit_clone isn’t expecting a loaded block object there.
You’re doing:
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:
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:
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:
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:
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:
python
def build_gitlab_url_with_token(repo_url: str, token: str) -> str:
return repo_url.replace("https://", f"<https://oauth2:{token}@>")
Then:
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:
python
cred = GitLabCredentials.load(CRED_BLOCK_NAME)Marvin
07/16/2026, 5:45 AMload() is async in your environment/integration, then cred may itself be a coroutine object unless awaited.
That would very neatly explain:
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:
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:
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:
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.Santiago Gutierrez
07/16/2026, 6:50 AMyaml (for both master.py and child s2_satproc_shared_dev deployments) design with gitclone step:
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:
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.Marvin
07/16/2026, 6:50 AMMarvin
07/16/2026, 6:51 AMworking_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:
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:
python
import uuid
clone_dir = working_dir / f"child-run-{uuid.uuid4().hex}"
Then:
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’sThat 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:value.working_dir
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,Marvin
07/16/2026, 6:51 AM---
# 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:
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:
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.Santiago Gutierrez
07/17/2026, 2:36 AMjob_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:
# 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?Marvin
07/17/2026, 2:36 AMMarvin
07/17/2026, 2:37 AMrun 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:
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:
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:
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:
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:
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 behaviorMarvin
07/17/2026, 2:37 AMpython
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
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
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
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.Santiago Gutierrez
07/17/2026, 3:00 AMSatProcTasks . 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.Marvin
07/17/2026, 3:00 AMMarvin
07/17/2026, 3:00 AMrun 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.Santiago Gutierrez
08/05/2026, 10:53 PMfor 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 ?Marvin
08/05/2026, 10:53 PMMarvin
08/05/2026, 10:54 PMasyncio approach.
---
# Recommended pattern
Use asyncio.as_completed(...) for the first wave, and inside that loop create follow-up tasks.
## Conceptual structure
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:
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:
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:
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
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.Marvin
08/05/2026, 10:54 PMpython
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:
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:
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
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`:
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.Santiago Gutierrez
08/07/2026, 12:25 AMpython
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:
@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() ?Marvin
08/07/2026, 12:25 AMMarvin
08/07/2026, 12:25 AMrun_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:
python
main_params = {
"import_file": d_fut, # not recommended
}
Instead, resolve the future first, then pass the actual result:
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:
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:
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:
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, taskMarvin
08/07/2026, 12:25 AMThen 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
python
async def download_api_item_async(item: UUID) -> Path:
...
## Submit child deployment after each download
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
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.:
python
@flow(task_runner=ThreadPoolTaskRunner(max_workers=8))
def download_flow(...):
...
Do not expect this to work inside another running flow:
python
with ThreadPoolTaskRunner(...) as runner:
my_task.map(...)
That will not switch the active task runner.Marvin
08/07/2026, 12:25 AMrun_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(...)Bring your towel and join one of the fastest growing data communities. Welcome to our second-generation open source orchestration platform, a completely rethought approach to dataflow automation.
Powered by