Santiago Gutierrez
03/09/2026, 3:07 AMDaskTaskRunner :
from dataclasses import dataclass
from typing import Dict, List
@dataclass
class CollectionData:
"""Data for a single collection query result."""
provider: str
download_links: List[str]
# Collect results
all_items: Dict[str, CollectionData] = {}
# This runs outside the Dask cluster
for coll in collections:
d_provider, download_links = get_new_items(
end_date=end_date,
n_days=n_days,
collection=coll, # Single collection, not collections
granules=granules,
aoi_filter=aoi_filter,
)
all_items[coll] = CollectionData(
provider=d_provider,
download_links=download_links
)
# run downloads using DaskTaskrunner
for collection, data in all_items.items():
_ = download.map(provider=unmapped(data.provider), links=data.download_links)
Will this work?Marvin
03/09/2026, 3:07 AMMarvin
03/09/2026, 3:10 AMdownload is a Prefect task
- You import unmapped from Prefect
- You call .map(...) from inside a @flow (mapping requires a flow context)
- You keep the returned futures and optionally wait on them before the flow exits
Here’s a complete example adapted to your design:
from dataclasses import dataclass
from typing import Dict, List
from prefect import flow, task, unmapped
from prefect_dask import DaskTaskRunner
@dataclass
class CollectionData:
provider: str
download_links: List[str]
# Example tasks
@task
def get_new_items(end_date, n_days, collection, granules, aoi_filter):
# return provider, list_of_links
...
@task
def download(provider: str, link: str) -> str:
# do the download and return a path or record
...
@flow(task_runner=DaskTaskRunner()) # or DaskTaskRunner(address="<tcp://scheduler:8786>")
def run_downloads(collections, end_date, n_days, granules, aoi_filter):
# This runs in the flow process (outside the Dask workers)
all_items: Dict[str, CollectionData] = {}
for coll in collections:
# If you want this strictly outside the Dask cluster, keep it as a plain function
# or call get_new_items.fn(...) to run synchronously in the flow process.
provider, links = get_new_items.fn(
end_date=end_date,
n_days=n_days,
collection=coll,
granules=granules,
aoi_filter=aoi_filter,
)
all_items[coll] = CollectionData(provider=provider, download_links=links)
# Map downloads per collection; provider is constant, links are mapped
all_futures = []
for collection, data in all_items.items():
futures = download.map(
provider=unmapped(data.provider),
link=data.download_links,
)
all_futures.extend(futures)
# Optionally wait for completion and collect results
results = [f.result() for f in all_futures]
return results
Notes and tips:
- Mapping semantics: at least one argument must be an iterable (link in your case). Anything wrapped with unmapped(...) is treated as a constant for every mapped run.
- Flow context: .map(...) must be called inside a @flow. If you truly need to map outside a flow, there’s a deferred=True mode, but that’s not typical for this pattern.
- Concurrency: with DaskTaskRunner, mapped tasks will execute in parallel on Dask workers. Your per-collection loops will still run concurrently across collections because the futures are independent.
- Remote clusters: if you connect to an external Dask cluster, make sure your task code and dependencies are available on the workers (e.g., same environment or packaged code).
- Alternative flattening pattern: you can build two flat lists (providers repeated per link, and links) and call a single download.map(provider=providers, link=links) if you prefer one large mapped call.
If you share how you plan to run Dask (local vs. external cluster) and whether get_new_items should be a task or a plain function, I can tailor the example further.Santiago Gutierrez
03/09/2026, 7:06 AM# run QV processing to GeoTIFFs, returns a list of lists
out_qvf_paths = process_hdf.map(hdf_path=all_hdfs) # returns a list with 2 items
# also returns a list of lists
out_qvf_names = save_qvf_files.map(qvfiles=out_qvf_paths, scriptname="modisimport_flow.py")
# lastly, update the db table with the new record, returns None
futures = update_db_table.map(qvf_fns=out_qvf_names)
done, not_done = wait(futures)
What am I gettin when I call future.result() with the following logic:
# now we compare what we created
# qv fns created are the output of out_qvf_names, which should now
# be returned as a result, so a list of lists
qvfs_created = list(chain.from_iterable(
future.result() for future in out_qvf_names
))
save_successes = len(qvfs_created)
# compare against download hdfs futures, 2 QV fns per downloaded HDF
save_failures = len(all_hdfs * 2) - save_successes
outputs = []
context = get_run_context()
flow_run_name = context.flow_run.name
msg_body = f"""import run Summary: {flow_run_name}"""
settings = get_current_settings()
# since the masking is batched up, these
# now refer to batch runs
successes = 0
failures = 0
for future in done:
if future.state.is_completed() and not future.state.is_failed():
outputs.append(future.result())
successes += 1
In particular, what futures does the done set holds?Marvin
03/09/2026, 7:07 AMMarvin
03/09/2026, 7:09 AMdone.
Assumptions:
- Prefect 3.x
- wait is imported from prefect.futures (important — not concurrent.futures)
What each mapped future returns:
- out_qvf_paths = process_hdf.map(hdf_path=all_hdfs)
- Type: PrefectFutureList[list[str]] (one future per HDF)
- Each future.result() here returns a list (e.g., a list of QV file paths for that HDF)
- out_qvf_names = save_qvf_files.map(qvfiles=out_qvf_paths, scriptname="modisimport_flow.py")
- Type: PrefectFutureList[list[str]]
- Each future.result() returns a list (e.g., normalized/saved QV filenames for that HDF)
- Your flattening is correct: it produces a single list of all QV filenames
qvfs_created = [name for future in out_qvf_names for name in future.result()]
- futures = update_db_table.map(qvf_fns=out_qvf_names)
- Type: PrefectFutureList[None] (if update_db_table returns None)
- Each future.result() will be None on success
What wait(futures) returns:
- done, not_done = wait(futures) returns two sets of Prefect futures:
- `done`: a set of PrefectFuture objects for the mapped update_db_table runs that reached a terminal state (Completed, Failed, or Cancelled)
- `not_done`: still running/pending
- In your loop:
for future in done:
if future.state.is_completed() and not future.state.is_failed():
outputs.append(future.result()) # This will append None for each success
successes += 1
- You’re correctly counting successful update_db_table runs
- Note: is_completed() already excludes failures, so and not future.state.is_failed() is redundant
Minor note on counts:
- This line works but is a bit surprising:
save_failures = len(all_hdfs * 2) - save_successes
It duplicates the list then counts. Equivalent and clearer:
save_failures = 2 * len(all_hdfs) - save_successes
References:
- Prefect futures/wait implementation: prefect/src/prefect/futures.py
- Task mapping (Task.map/unmapped): prefect/src/prefect/tasks.py
If you want, I can suggest a version that gathers success/failure counts directly from the states without building outputs (since they’re None).Santiago Gutierrez
03/09/2026, 7:40 AMoutputs in this case. Thanks.
However, I am not sure that I am correctly differentiating the number futures of these task runs vs. the future.result() counts as inputs/outputs are not the same across all my task runs.
To summarize the flow run results, I have this recipe:
# run QV processing to GeoTIFFs, returns a list of lists
out_qvf_paths = process_hdf.map(hdf_path=all_hdfs)
# also returns a list of lists
out_qvf_names = save_qvf_files.map(qvfiles=out_qvf_paths, scriptname="my.py")
# lastly, update the db table with the new record, returns None
futures = update_db_table.map(qvf_fns=out_qvf_names)
done, not_done = wait(futures)
# now we compare what we created
# qv fns created are the output of out_qvf_names, which should now
# be returned as a result, so a list of lists
qvfs_created = list(chain.from_iterable(
future.result() for future in out_qvf_names
))
save_successes = len(qvfs_created)
# compare against download hdfs futures, 2 QV fns per downloaded HDF
save_failures = 2 * len(all_hdfs) - save_successes
context = get_run_context()
flow_run_name = context.flow_run.name
msg_body = f"""flow run Summary: {flow_run_name}"""
successes = 0
failures = 0
for future in done:
if future.state.is_completed():
successes += 1 # nothing to collect, as they hold None
else:
task_id = future.task_run_id
url = f"{settings.ui_url}/runs/task-run/{task_id}"
msg_body += f"\n task {task_id} is incomplete: state is {future.state} {url}\n"
failures += 1
# summarise results here
msg_body += (f"\n\n failed: {failures}\nsucceeded: {successes}\nimported: {save_successes}\n"
f"import fails: {save_failures}")
<http://logger.info|logger.info>(f"tasks failed: {failures}")
<http://logger.info|logger.info>(f"tasks succeeded: {successes}")
<http://logger.info|logger.info>(f"imported: {save_successes}")
<http://logger.info|logger.info>(f"Not imported: {save_failures}")
# send email report
my_package.sendMsg(["recipient1"], "my subject", msg_body)Marvin
03/09/2026, 7:40 AMMarvin
03/09/2026, 7:41 AMfutures (one per HDF)
- Counting created QV files: flatten results from out_qvf_names (list-of-lists), but guard against failures
A couple of fixes I recommend:
- Use out_qvf_names.result(raise_on_failure=False) so your summary doesn’t blow up if any save task failed (those entries will be None)
- Compute DB successes/failures from states after wait(futures) (no timeout = not_done should be empty)
- Keep the “2 per HDF” expectation clear and computed from len(all_hdfs)
Here’s a tightened-up summary section:
from itertools import chain
from prefect.futures import wait
from prefect import get_run_context
from prefect.settings import get_current_settings
# ... your maps ...
out_qvf_paths = process_hdf.map(hdf_path=all_hdfs)
out_qvf_names = save_qvf_files.map(qvfiles=out_qvf_paths, scriptname="my.py")
futures = update_db_table.map(qvf_fns=out_qvf_names)
# Ensure DB updates are finished (blocks until all are terminal)
done, not_done = wait(futures)
# Safely collect created QV filenames even if some saves failed
# (failed entries will be None)
qvf_lists = out_qvf_names.result(raise_on_failure=False)
qvfs_created = [name for lst in qvf_lists if lst is not None for name in lst]
save_successes = len(qvfs_created)
expected_qvfs = 2 * len(all_hdfs)
save_failures = max(0, expected_qvfs - save_successes)
# Count DB update results
db_successes = sum(1 for f in done if f.state.is_completed())
db_failures = len(done) - db_successes # failed/cancelled
context = get_run_context()
flow_run_name = context.flow_run.name
settings = get_current_settings()
msg_body = (
f"flow run Summary: {flow_run_name}"
f"\n\nsave failed: {save_failures}"
f"\nsave succeeded: {save_successes}"
f"\nDB updates failed: {db_failures}"
f"\nDB updates succeeded: {db_successes}"
)
# Optionally include links for failed DB updates
for f in done:
if not f.state.is_completed():
task_id = f.task_run_id
url = f"{settings.ui_url}/runs/task-run/{task_id}"
msg_body += f"\nDB update task {task_id} incomplete: {f.state} {url}"
<http://logger.info|logger.info>(f"save succeeded: {save_successes}")
<http://logger.info|logger.info>(f"save failed: {save_failures}")
<http://logger.info|logger.info>(f"DB updates succeeded: {db_successes}")
<http://logger.info|logger.info>(f"DB updates failed: {db_failures}")
my_package.sendMsg(["recipient1"], "my subject", msg_body)
Why this helps:
- out_qvf_names.result(raise_on_failure=False) won’t raise if a save failed; you just skip None entries when flattening
- done is a set of the update_db_table futures that have reached a terminal state; with no timeout, not_done should be empty. Counting is_completed() gives you DB successes; everything else in done is a failure/cancel
- The counts now reflect:
- save_successes/save_failures = file-level outputs
- db_successes/db_failures = per-HDF DB updates
Links for reference:
- Prefect futures and wait: prefect/src/prefect/futures.py
- Task mapping/unmapped: prefect/src/prefect/tasks.py
If you want per-HDF detail (e.g., which HDF saved how many files and its DB update state), I can show a small zip over (out_qvf_paths, out_qvf_names, futures) that builds a per-item summary.Santiago Gutierrez
03/09/2026, 7:50 AMMarvin
03/09/2026, 7:50 AMMarvin
03/09/2026, 7:51 AMMarvin
03/09/2026, 7:51 AM.state to classify outcomes (Completed/Failed/Cancelled), which is robust even if a task failed upstream and the downstream was cancelled.
- It uses .result(raise_on_failure=False) only to gather lists for counting, avoiding exceptions.
- You get clickable UI links for any problematic task run.
If you want to persist per_item as a JSON artifact (for later inspection), we can add a small helper to write it to storage or log it as a Prefect artifact. For reference on futures and mapping: futures and tasks.Santiago Gutierrez
03/11/2026, 10:31 PM# ... your maps ...
# Map downloads per collection; provider is constant, links are mapped
all_hdfs = []
for collection, data in all_items.items():
futures = download.map(
provider=unmapped(data.provider),
link=data.download_links,
)
all_hdfs.extend(futures)
out_qvf_paths = process_hdf.map(hdf_path=all_hdfs)
out_qvf_names = save_qvf_files.map(qvfiles=out_qvf_paths, scriptname="my.py")
futures = update_db_table.map(qvf_fns=out_qvf_names)
# Ensure DB updates are finished (blocks until all are terminal)
done, not_done = wait(futures)
# Safely collect created QV filenames even if some saves failed
# (failed entries will be None)
qvf_lists = out_qvf_names.result(raise_on_failure=False)
qvfs_created = [name for lst in qvf_lists if lst is not None for name in lst]
save_successes = len(qvfs_created)
expected_qvfs = 2 * len(all_hdfs)
save_failures = max(0, expected_qvfs - save_successes)
Should I also inspect which downloads failed (the all_hdfs futures) to get a more accurate report?Marvin
03/11/2026, 10:32 PMMarvin
03/11/2026, 10:34 PM.result(raise_on_failure=False), not None. Your comprehension will break when it tries to iterate an Exception. Handle exceptions explicitly:
# Collect save results safely
qvf_lists = out_qvf_names.result(raise_on_failure=False)
# Files actually created
qvfs_created = [
name
for lst in qvf_lists
if isinstance(lst, list) # only flatten successful list-returning tasks
for name in lst
]
# Optional: capture save errors for reporting
save_errors = [e for e in qvf_lists if isinstance(e, Exception)]
2) Compute “expected” from successful upstreams
If each successful download should produce 2 QVFs, count only the downloads that actually succeeded:
# Determine which downloads succeeded
download_results = [f.result(raise_on_failure=False) for f in all_hdfs]
successful_downloads = [r for r in download_results if not isinstance(r, Exception)]
expected_qvfs = 2 * len(successful_downloads)
save_successes = len(qvfs_created)
save_failures = max(0, expected_qvfs - save_successes)
Alternatively, if “expected” should reflect the last successful stage before saving, base it on successful process_hdf results instead:
proc_results = out_qvf_paths.result(raise_on_failure=False)
successful_proc = [r for r in proc_results if not isinstance(r, Exception)]
expected_qvfs = 2 * len(successful_proc)
save_failures = max(0, expected_qvfs - save_successes)
3) Optional: capture download failures explicitly
This gives you a clean funnel report (downloads -> processed -> saved):
download_errors = [
r for r in download_results if isinstance(r, Exception)
]
proc_errors = [
r for r in proc_results if isinstance(r, Exception)
]
# save_errors already computed above
This way you can distinguish:
- Save failures (save task errored when inputs were good)
- Skips/upstream failures (no save because download/process failed)
If you share what each task returns on success/failure, I can tailor the checks (e.g., list vs path vs None) so your counts are perfectly aligned.Santiago Gutierrez
03/11/2026, 10:48 PM# run search collecting results iteratively (not using the DaskTaskRunner)
# search results are collected in a dictionary -> all_items
# per-collection loops will still run concurrently with the DaskTaskRunner
# because the futures are independent
all_hdfs = []
if not all_items:
logger.warning("No items found across all collections, no further processing.")
for coll, data in all_items.items(): # where data is a simple dataclass object with two attributes, a str and a list[str]
<http://logger.info|logger.info>(f"Launching downloads for {coll} collection")
out_hdfs = download_hdfs.map(
provider=unmapped(data.provider), d_link=data.download_links # this is a list
)
all_hdfs.extend(out_hdfs)
# run QV processing to GeoTIFFs, returns a list (PrefectFutureList) of SaveFiles (a dataclass with 3 attributes)
out_save_files = process_hdf.map(hdf_path=all_hdfs)
# returns a list (PrefectFutureList) of lists [["save_file1", "save_file2"], ...]
out_qvf_names = save_files.map(
save_files=out_save_files, scriptname="modisimport_flow.py"
)
# lastly, update the db table with the new record, returns None
futures = update_db_table.map(qvf_fns=out_qvf_names)
done, not_done = wait(futures)Marvin
03/11/2026, 10:48 PMMarvin
03/11/2026, 10:50 PMwait(futures) to block on the DB updates.
A few refinements to make your reporting accurate and robust:
- When you collect mapped results, handle exceptions explicitly (failed tasks return an Exception object when you use raise_on_failure=False, not None).
- Base “expected” counts on the last successful upstream stage that guarantees the 1→2 QVF fan-out (typically process_hdf, or download_hdfs if that’s your contract).
- Optionally, capture errors at each stage so you can report a clean funnel (downloads → processed → saved → DB updates).
- If all_items ends up empty, consider short-circuiting before you call wait on an empty list.
Here’s a compact pattern you can drop in after your current code:
# Guard: no DB updates to wait on
if not futures:
<http://logger.info|logger.info>("No DB updates to apply.")
else:
done, not_done = wait(futures)
# 1) Downloads
download_results = [f.result(raise_on_failure=False) for f in all_hdfs]
dl_ok = [r for r in download_results if not isinstance(r, Exception)]
dl_errs = [e for e in download_results if isinstance(e, Exception)]
# 2) Process HDFs
proc_results = out_save_files.result(raise_on_failure=False)
proc_ok = [r for r in proc_results if not isinstance(r, Exception)]
proc_errs = [e for e in proc_results if isinstance(e, Exception)]
# 3) Save files (returns list[str] per item)
save_results = out_qvf_names.result(raise_on_failure=False)
qvfs_created = [name for lst in save_results if isinstance(lst, list) for name in lst]
save_errs = [e for e in save_results if isinstance(e, Exception)]
# 4) DB updates
update_results = [f.result(raise_on_failure=False) for f in futures] if futures else []
update_errs = [e for e in update_results if isinstance(e, Exception)]
# Expected vs actual (choose the correct upstream for your contract)
expected_qvfs = 2 * len(proc_ok) # or: 2 * len(dl_ok)
save_failures = max(0, expected_qvfs - len(qvfs_created))
<http://logger.info|logger.info>(
"Summary: downloads ok=%d err=%d | processed ok=%d err=%d | "
"saved files=%d (expected=%d, missing=%d) | db_update_err=%d | not_done=%d",
len(dl_ok), len(dl_errs),
len(proc_ok), len(proc_errs),
len(qvfs_created), expected_qvfs, save_failures,
len(update_errs), len(not_done) if futures else 0,
)
Optional enhancements:
- If you want to stream progress and start DB updates ASAP, iterate with as_completed(out_qvf_names) and map updates as each save finishes.
- If you truly aren’t using Dask, the default ConcurrentTaskRunner will still execute these maps concurrently; your comment mentions Dask — confirm which runner you’ve configured so expectations match.
If you share what each task returns on success (e.g., paths vs dataclasses) I can tailor the type checks further.