oleander-whirlwind
06/05/2026, 9:54 AMMarvin
06/05/2026, 9:54 AMMarvin
06/05/2026, 9:56 AMtask.map(...) is meant for concurrent submission/execution. So:
- Returned results keep the same order as the inputs
- The mapped task runs may start/finish in any order
- If you need strict in-order execution, .map() is usually not the right primitive by itself
Example:
python
from prefect import flow, task
import time
@task
def work(x):
time.sleep(3 - x)
print(f"finished {x}")
return x
@flow
def my_flow():
futures = work.map([1, 2, 3])
print(futures.result())
my_flow()
You might see:
text
finished 3
finished 2
finished 1
[1, 2, 3]
So execution finished out of order, but results came back in input order.
If you need strict sequential execution, use one of these patterns:
1. Loop instead of map
python
from prefect import flow, task
@task
def work(x):
print(f"running {x}")
return x
@flow
def my_flow():
results = []
for x in [1, 2, 3]:
results.append(work(x))
return results
2. Submit tasks with explicit dependencies
python
from prefect import flow, task
@task
def work(x):
print(f"running {x}")
return x
@flow
def my_flow():
futures = []
previous = None
for x in [1, 2, 3]:
if previous is None:
fut = work.submit(x)
else:
fut = work.submit(x, wait_for=[previous])
futures.append(fut)
previous = fut
return [f.result() for f in futures]
3. Use a single-worker task runner if you want mapped tasks to run one at a time:
python
from prefect import flow, task
from prefect.task_runners import ThreadPoolTaskRunner
@task
def work(x):
print(f"running {x}")
return x
@flow(task_runner=ThreadPoolTaskRunner(max_workers=1))
def my_flow():
futures = work.map([1, 2, 3])
return futures.result()
Short version:
- Want parallel work with deterministic result ordering → use .map()
- Want strict ordered execution → use a loop or explicit wait_for chaining
Docs/source-backed guidance:
- Prefect docs
- Prefect source
If you want, I can also show the best Prefect 3 pattern for “map, but only N at a time and still preserve logical order.”oleander-whirlwind
06/05/2026, 9:59 AMMarvin
06/05/2026, 9:59 AMMarvin
06/05/2026, 10:00 AMpython
from dataclasses import dataclass
from prefect import flow, task
@dataclass(frozen=True)
class TimeSlice:
start: str
end: str
index: int
@task(retries=3, retry_delay_seconds=10)
def download_slice(slice_: TimeSlice):
# replace with your actual API / file download
data = {
"index": slice_.index,
"start": slice_.start,
"end": slice_.end,
"payload": f"data-for-{slice_.start}-{slice_.end}"
}
return data
@flow
def download_all(slices: list[TimeSlice]):
futures = download_slice.map(slices)
results = futures.result()
# enforce deterministic order after parallel execution
results = sorted(results, key=lambda x: x["index"])
# optional validation: verify no slices missing
expected = [s.index for s in slices]
actual = [r["index"] for r in results]
if actual != expected:
raise ValueError(f"Missing or duplicated slices. expected={expected} actual={actual}")
return results
Why I like this approach:
- Fast: downloads happen concurrently
- Safe: retries can recover transient failures
- Deterministic: final output is ordered by your explicit index
- Auditable: you can verify every slice was downloaded
A few recommendations:
1. Make slices explicit and stable
Give every slice a unique identifier like:
- index
- start
- end
That makes validation easy.
2. Keep tasks independent
Each download task should only download one slice. Avoid making later slices depend on earlier ones unless the API requires it.
3. Validate completeness explicitly
Don’t assume success because the flow finished. Check:
- expected number of slices == returned number of slices
- no duplicate indices
- optional: no empty payloads
Example:
python
if len(results) != len(slices):
raise ValueError("Some slices did not download")
4. Use retries on the task
This is especially important for network downloads.
python
@task(retries=3, retry_delay_seconds=30)
def download_slice(slice_: TimeSlice):
...
5. Control concurrency if needed
If the upstream API has rate limits, don’t blast it with unlimited parallelism. You can limit concurrency with your task runner or work pool / infrastructure setup.
If you’re running tasks in-process, a common option is a thread pool:
python
from prefect import flow, task
from prefect.task_runners import ThreadPoolTaskRunner
@flow(task_runner=ThreadPoolTaskRunner(max_workers=5))
def download_all(slices):
futures = download_slice.map(slices)
return futures.result()
That gives you “up to 5 concurrent downloads”.
When would I avoid parallelizing?
I’d go sequential only if:
- the remote system is very rate-limited
- slices must be requested in order because of a cursor/token dependency
- the source breaks under concurrency
Otherwise, parallel download + ordered aggregation is usually the best tradeoff.
Practical strategy I’d recommend
- Build sorted slices first
- Download with bounded concurrency
- Retry transient failures
- Sort outputs by index
- Validate no gaps before downstream processing
If you want, I can turn this into a complete Prefect 3 flow for:
- date ranges
- hourly/daily partitions
- API pagination
- S3/file output per sliceoleander-whirlwind
06/05/2026, 10:02 AMMarvin
06/05/2026, 10:02 AMMarvin
06/05/2026, 10:02 AMpython
from prefect import flow, task
from prefect.task_runners import ThreadPoolTaskRunner
@task(retries=3, retry_delay_seconds=10)
def fetch_slice(slice_spec):
# download / transform in memory or temp file
return {
"index": slice_spec["index"],
"data": ...,
"region": slice_spec["region"],
}
@task
def write_slice_to_zarr(item, store_path):
# perform the actual zarr write here
...
return item["index"]
@flow(task_runner=ThreadPoolTaskRunner(max_workers=8))
def ingest_flow(slices, store_path):
fetched = fetch_slice.map(slices)
items = fetched.result()
for item in sorted(items, key=lambda x: x["index"]):
write_slice_to_zarr(item, store_path)
This is the safest general pattern.
Option B: Parallel fetch, parallel write to isolated temp stores, final combine
If write operations are heavy, another good pattern is:
- each slice writes to its own temporary zarr store
- then a final sequential combine step merges them
This avoids concurrent mutation of the same target store.
Option C: Parallel region writes only if preallocated and non-overlapping
This is the only case where I’d be comfortable with concurrent writes to one target:
- target Zarr structure is created up front
- shape/chunks/dtypes/coords are fixed
- each task writes only to a distinct region
- no appends/resizes during mapped execution
- metadata consolidation happens once at the end
Conceptually:
python
@task
def write_region(slice_spec, store_path):
# open pre-created store
# write only to assigned region
# do not resize, append, or rewrite global metadata
...
Even here, I’d test carefully on your exact backend, because correctness depends on:
- local filesystem vs object storage
- zarr version
- xarray/zarr integration behavior
- synchronizer/locking behavior
- chunk boundaries
What I would avoid
I would avoid patterns like:
python
write_slice.map(slices)
where each mapped task does something like:
- ds.to_zarr(store, append_dim="time")
- mode="a" with evolving schema
- writes that recalculate shared coords/attrs
- metadata consolidation inside every task
Those patterns are very prone to race conditions and corruption or partial writes.
Recommended decision framework
Ask these:
1. Does each task write to a disjoint region?
- If no: serialize writes
2. Is the store fully initialized before parallel tasks start?
- If no: serialize initialization
3. Do tasks mutate shared metadata or append along a dimension?
- If yes: serialize
4. Can each task write to its own temporary target first?
- If yes: that’s often the best compromise
So my practical recommendation is:
- If you’re appending time slices into one Zarr store: serialize writesMarvin
06/05/2026, 10:02 AMpython
from prefect import flow, task
from prefect.task_runners import ThreadPoolTaskRunner
@task(retries=3)
def prepare_slice(slice_spec):
# fetch/download/transform
return prepared_artifact_for_slice
@task
def write_slice(prepared, store_path):
# the only task that mutates the canonical zarr store
...
@flow(task_runner=ThreadPoolTaskRunner(max_workers=8))
def ingest(slices, store_path):
prepared_futures = prepare_slice.map(slices)
prepared = prepared_futures.result()
for item in sorted(prepared, key=lambda x: x["index"]):
write_slice(item, store_path)
If you want higher throughput while still protecting the store, you can make this a small pipeline:
- many concurrent prepare tasks
- one write stage consuming in order
That’s usually the right shape for shared-storage ingestion.
If you want, I can help sketch a Prefect + xarray/zarr-safe pattern for one of these exact cases:
1. xarray.Dataset.to_zarr(..., append_dim="time")
2. region writes with region=...
3. one temp store per slice + later combine
4. object storage backend like S3/GCS/Azure
If you tell me how you’re writing to Zarr now, I can recommend the safest parallelization pattern.oleander-whirlwind
06/05/2026, 10:06 AMMarvin
06/05/2026, 10:06 AMMarvin
06/05/2026, 10:07 AMpython
from prefect import flow, task
from prefect.task_runners import ThreadPoolTaskRunner
@task(retries=3, retry_delay_seconds=10)
def fetch_and_write_region(slice_spec, store_path):
# 1. fetch remote data for this slice
# 2. transform into exact array block
# 3. open the pre-created zarr store
# 4. write only to this slice's assigned region
#
# Important:
# - no append_dim
# - no resize
# - no per-task metadata consolidation
# - no overlapping regions
#
return slice_spec["index"]
@task
def validate_written_slices(written_indices, expected_indices):
if sorted(written_indices) != sorted(expected_indices):
raise ValueError(
f"Missing writes: expected={expected_indices}, got={written_indices}"
)
@flow(task_runner=ThreadPoolTaskRunner(max_workers=4))
def ingest_flow(slices, store_path):
futures = fetch_and_write_region.map(slices, unmapped(store_path))
written = futures.result()
validate_written_slices(written, [s["index"] for s in slices])
A few important design points:
1. Use an explicit slice manifest
Before running, build a manifest like:
python
[
{
"index": 0,
"time_start": "...",
"time_end": "...",
"region": {"time": slice(0, 24)},
},
{
"index": 1,
"time_start": "...",
"time_end": "...",
"region": {"time": slice(24, 48)},
},
]
That manifest is the source of truth for both:
- sort order
- final destination region
This is much better than relying on task completion order.
2. Initialize the store once
Do one setup step that:
- creates the Zarr arrays
- fixes chunking
- fixes coordinates if possible
- establishes the full target schema
Then mapped tasks only fill data regions.
3. Avoid append semantics entirely
If you’re currently using something like:
python
ds.to_zarr(..., append_dim="time")
I would be very cautious with parallelism. On S3 especially, that’s where races and metadata issues appear.
Parallel-safe is much more like:
- store exists already
- write to region=...
- no schema evolution during the run
4. Keep metadata writes out of mapped tasks
Mapped tasks should ideally only write array chunks / assigned region data.
Then at the end, do one final task for:
- metadata update if needed
- consolidated metadata if needed
- marking the dataset complete
5. Use bounded concurrency, not max concurrencyMarvin
06/05/2026, 10:07 AMunmapped(store_path) with .map(), that import comes from Prefect. I can give you a full verified snippet if useful.
Docs:
- Prefect docs
- Prefect source
If you want, I can help you design the manifest and task structure around your exact write pattern, for example:
- xarray.to_zarr(region=...)
- raw zarr.Array[...] = ...
- S3-backed store via s3fs
- time dimension with fixed chunk layout
If you paste a small version of your current Zarr write code, I can suggest the safest Prefect parallelization pattern.oleander-whirlwind
06/05/2026, 10:09 AMMarvin
06/05/2026, 10:09 AMMarvin
06/05/2026, 10:10 AMslice_id or index
- source time bounds
- a temp object path in S3
- a final destination mapping in the Zarr array
- status/checksum/size if possible
Something like:
python
{
"index": 17,
"start": "2024-01-17T00:00:00Z",
"end": "2024-01-18T00:00:00Z",
"temp_uri": "<s3://bucket/tmp/run-123/slice-00017.parquet>",
"final_region": {"time": [408, 432]}
}
Then Prefect handles each phase.
A good design is:
Phase 1: Parallel stage
- download slice
- transform it into a durable intermediate representation
- write to temp S3 object
- return metadata only, not the payload
Phase 2: Validate staged outputs
- confirm every expected slice exists
- confirm sizes/checksums if possible
- sort by index
Phase 3: Serialized commit
- for each staged slice in order:
- read temp object
- write into canonical Zarr store
- optionally mark committed
- if desired, cleanup immediately after each successful commit
This avoids keeping all slices in memory and avoids concurrent mutation of the final Zarr store.
Example skeleton:
python
from prefect import flow, task
from prefect.task_runners import ThreadPoolTaskRunner
@task(retries=3, retry_delay_seconds=30)
def stage_slice(slice_spec, temp_prefix):
temp_uri = f"{temp_prefix}/slice-{slice_spec['index']:05d}.bin"
# download and transform slice
# write durable temp artifact to temp_uri
return {
"index": slice_spec["index"],
"temp_uri": temp_uri,
"start": slice_spec["start"],
"end": slice_spec["end"],
}
@task
def validate_stage_outputs(staged, expected_count):
if len(staged) != expected_count:
raise ValueError(f"Expected {expected_count} staged slices, got {len(staged)}")
indices = sorted(x["index"] for x in staged)
expected = list(range(expected_count))
if indices != expected:
raise ValueError(f"Missing or duplicate slices: {indices}")
return sorted(staged, key=lambda x: x["index"])
@task(retries=2, retry_delay_seconds=10)
def commit_slice_to_zarr(staged_item, zarr_store):
# read staged_item["temp_uri"]
# write to final zarr store in the correct region
return staged_item["index"]
@task
def cleanup_temp(staged_item):
# delete staged_item["temp_uri"]
pass
@flow(task_runner=ThreadPoolTaskRunner(max_workers=8))
def ingest_flow(slices, temp_prefix, zarr_store):
staged = stage_slice.map(slices, unmapped(temp_prefix))
staged_items = validate_stage_outputs(staged.result(), len(slices))
for item in staged_items:
commit_slice_to_zarr(item, zarr_store)
cleanup_temp(item)
Why this is a good fit for your constraints
- no need to keep all slices in memory
- download throughput is still parallelized
- final Zarr mutations are serialized and safer
- ordering is enforced during commit, not download
- failed commits can be retried from temp artifacts without re-downloading
That last point is important: temp storage decouples network acquisition from Zarr mutation. That’s often worth the complexity.
What should the temp format be?Marvin
06/05/2026, 10:10 AMindex
- temp_uri
- final_region
- make tasks return and operate on that manifest
Then your temp object name can literally encode the index:
text
<s3://bucket/tmp/run-123/slice-000017.bin>
This makes reconciliation much easier.
One improvement: stream commit instead of waiting for all stages to finish
If you want to reduce temp storage footprint, the best version is a producer-consumer pattern:
- stage tasks run in parallel
- each writes temp artifact
- a single commit lane consumes completed staged slices in order
- committed temp artifacts are deleted quickly
That’s trickier than a simple .map(...).result() because “finished staging” may happen out of order while “commit” must happen in order.
In plain Prefect, the simpler implementation is usually:
- stage all
- validate
- commit in order
But if temp storage size matters a lot, you can build a more streaming approach.
My recommendation hierarchy
Given what you’ve described, I’d rank options like this:
1. Best safety: parallel stage to temp + serialized commit to final Zarr
2. Best simplicity if safe: direct parallel writes to preallocated non-overlapping Zarr regions
3. Worst fit here: parallel appends directly into shared Zarr
4. Expensive operationally: per-slice temp Zarr stores and later merge
So yes — I agree with your conclusion: if in-memory buffering is not possible and shared writes aren’t safely parallel, temp storage per slice plus serialized final write is the practical architecture.
If you want, I can help with one of these next:
1. a full Prefect 3 flow for stage → validate → commit → cleanup
2. a streaming ordered commit design so you don’t need all temp slices at once
3. advice on temp object naming/manifest schema for S3
4. a pattern for retrying commit without repeating downloadoleander-whirlwind
06/05/2026, 10:19 AMMarvin
06/05/2026, 10:19 AMMarvin
06/05/2026, 10:20 AM.npy / .npz if it’s basically dense arrays
- Raw compressed bytes plus a small JSON sidecar if you already know the exact shape/dtype/coords
- Pickle only if this is internal-only and you control both ends, but I’d avoid it if possible
My main advice:
Choose the temp format that makes the commit step trivial.
For example, if each slice naturally becomes:
- one dense block of values
- fixed coords / known time span
- a known destination region
then a very simple temp artifact is often enough:
- slice-00017.npy for the data
- slice-00017.json for metadata like index, shape, dtype, region
That is usually much easier operationally than temp Zarr.
So the pipeline becomes:
1. build sorted slice manifest
2. parallel tasks download + transform each slice
3. each task writes a temp artifact to S3
4. final serial pass reads temp artifacts in manifest order
5. each artifact is written into the target Zarr store
6. delete temp artifacts after success
The key is: ordering comes from the manifest, not from task execution or S3 listing order.
Example manifest:
python
[
{
"index": 0,
"start": "2024-01-01T00:00:00Z",
"end": "2024-01-01T01:00:00Z",
"temp_data_uri": "<s3://bucket/tmp/run-123/slice-00000.npy>",
"temp_meta_uri": "<s3://bucket/tmp/run-123/slice-00000.json>",
"final_region": {"time_start": 0, "time_stop": 24},
},
...
]
That way the temp layer is just a durable spool, not a second storage system.
What I would do in practice
If your slices are regular multidimensional arrays:
- temp format: .npy or .npz
- metadata: JSON sidecar or embedded in task return value
If your slices are more dataset-like:
- temp format: parquet or netcdf, whichever is simpler for reconstruction
If the slices are very large:
- keep one artifact per slice
- compress if CPU allows it
- avoid too many tiny objects in S3
Why this is better than trying to force ordered map execution
Because your real problem isn’t task ordering — it’s safe buffering and deterministic commit. Once you have temp artifacts plus a manifest, execution order matters much less.
You can let staging finish in any order, then do:
python
staged_items = sorted(staged_items, key=lambda x: x["index"])
for item in staged_items:
commit_to_zarr(item)
That’s the important order.
One more architectural thought
If temp storage is unavoidable, consider making the temp artifact already aligned to the final Zarr chunk or region. Then the commit step becomes mostly:
- load artifact
- write exact region
- mark committed
That minimizes transformation work during the serial phase.
So my recommendation is:
- Yes to temp storage
- No to temp Zarr
- Use a simple artifact format per slice
- Use Prefect to parallelize staging and serialize final commit
If you want, I can help you choose the temp format if you describe one slice a bit more:
- shape/dimensions
- approximate size per slice
- xarray dataset vs ndarray vs tabular
- whether coordinates/attrs matter for each slice
- whether final writes are region writes or appends
With that, I can suggest the least painful temp format and a clean Prefect flow shape.oleander-whirlwind
06/08/2026, 5:18 AMMarvin
06/08/2026, 5:18 AMMarvin
06/08/2026, 5:19 AM1
That behaves a lot like a distributed mutex.
I want to be careful here: this is better expressed as concurrency control on the write task than “force .map() order”.
So a better architecture would be:
- fetch_slice task: parallel
- write_slice task: globally limited to 1 concurrent run
If your prepared slice must be passed directly from fetch to write, you can chain them per-slice. But if the object is too large to pass around, then the lock alone won’t solve that buffering issue.
The cleanest shape is usually two tasks per slice:
python
@task
def fetch_slice(slice_spec):
# download / prepare
return prepared_slice
@task
def write_slice(prepared_slice, store_path):
# write to shared zarr store
...
Then make write_slice single-concurrency.
However, if prepared_slice is too large to keep in memory or pass between tasks, then again, you’re back to either:
- writing directly within one task under the lock, or
- staging to temp storage
So there are really three viable models:
Model A: Single task with internal lock
- task downloads slice
- task acquires lock
- task writes to Zarr
- task releases lock
Best when slice memory is manageable and simplest implementation matters.
Model B: Fetch task + serialized write task
- good if prepared payloads are not huge
Model C: Stage to temp + serialized commit
- best when payloads are large or commit retries must not trigger re-downloadMarvin
06/08/2026, 5:19 AM