Tanya Hristova
10/31/2025, 10:17 AMMarvin
10/31/2025, 10:17 AMMarvin
10/31/2025, 10:26 AMsearch_api.map(queries) and process_record.map(records).
- Use as_completed to start post-processing as soon as each search returns.
- Control concurrency with a task runner (threads for I/O) and optionally global concurrency limits/rate limits for APIs.
- Add retries with backoff on the API calls.
Pattern A — Streamed: begin processing each list as soon as it’s ready
This minimizes end-to-end latency and doesn’t wait for all searches to finish.
from prefect import flow, task
from prefect.task_runners import ThreadPoolTaskRunner
from prefect.futures import as_completed
from prefect.tasks import exponential_backoff
from prefect.concurrency.sync import rate_limit
@task(retries=3, retry_delay_seconds=exponential_backoff(2), log_prints=True)
def search_api(query: str) -> list[dict]:
# Optional: enforce API rate limits globally (see CLI below)
with rate_limit("my-api", timeout_seconds=30, strict=False):
# call external API here
# return a list of records
...
@task
def process_record(rec: dict) -> dict:
# post-process a single record
...
@flow(task_runner=ThreadPoolTaskRunner(max_workers=20))
def pipeline(queries: list[str]):
# Fan-out searches concurrently
search_futures = search_api.map(queries)
processed = []
# As each search finishes, map the processing over its returned list
for f in as_completed(search_futures):
records = f.result() # this is the list from that search
processed.extend(process_record.map(records))
# Optionally wait for all processing to complete and return results
return [p.result() for p in processed]
Pattern B — Batch: wait for all searches, then process all records
This is simpler but waits for the entire first stage to finish.
from prefect import flow, task
from prefect.task_runners import ThreadPoolTaskRunner
from prefect.tasks import exponential_backoff
from prefect.concurrency.sync import rate_limit
@task(retries=3, retry_delay_seconds=exponential_backoff(2))
def search_api(query: str) -> list[dict]:
with rate_limit("my-api", timeout_seconds=30, strict=False):
...
@task
def process_record(rec: dict) -> dict:
...
@flow(task_runner=ThreadPoolTaskRunner(max_workers=20))
def pipeline(queries: list[str]):
lists = search_api.map(queries).result() # resolve to list-of-lists
flat_records = [rec for lst in lists for rec in lst]
processed = process_record.map(flat_records)
return [p.result() for p in processed]
Concurrency and rate limiting
- Task runner: For I/O-bound HTTP calls, threads are ideal. Control concurrency with max_workers on ThreadPoolTaskRunner. For CPU-heavy post-processing, consider ProcessPoolTaskRunner or Dask if you need to scale out.
- Global Concurrency Limits and Rate Limits: Use these to respect third-party API limits across all flows.
- Create a limit:
- List commands: prefect gcl --help
- Create: prefect gcl create my-api --limit 10 --slot-decay-per-second 10
- Use in tasks:
from prefect.concurrency.sync import rate_limit, concurrency
# Rate limit (token bucket style)
with rate_limit("my-api", timeout_seconds=30, strict=False):
...
# Or a simple slot-based concurrency cap
with concurrency("my-api-requests", occupy=1, timeout_seconds=30, strict=False):
...
- CLI help verified:
- Global concurrency limits docs
- prefect gcl create --help confirmed via CLI
Reliability tweaks
- Retries with backoff on network calls:Marvin
10/31/2025, 10:26 AM@task(retries=3, retry_delay_seconds=exponential_backoff(2))
- Docs: Retries and backoff
- If individual records can fail but you want the flow to continue, you can wrap them with allow_failure when mapping.
- If you need to observe futures as they complete: as_completed is great; wait is useful to partition into done/not-done if you need timeouts.
When to pick which pattern
- Use Pattern A (streamed) if you want to minimize latency and start processing as soon as possible.
- Use Pattern B (batch) if simplicity is more important and waiting for all searches first is okay.
Helpful docs
- Run work concurrently: https://docs-3.prefect.io/v3/how-to-guides/workflows/run-work-concurrently
- Futures API: https://docs-3.prefect.io/v3/api-ref/python/prefect-futures
- Dask integration (if you need distributed): https://docs-3.prefect.io/integrations/prefect-dask/index
A couple clarifying questions to recommend the exact pattern:
- Do you want processing to start as soon as each search finishes, or is batching fine?
- Roughly how many queries and records are we talking about (to size concurrency and decide if chunking is needed)?
- Any specific API rate limits we should enforce (e.g., X requests per second)?Tanya Hristova
10/31/2025, 12:31 PMas_completed method.Marvin
10/31/2025, 12:31 PMMarvin
10/31/2025, 12:35 PMprefect.futures.as_completed in Prefect 3.x.
What it does
- It’s a generator that yields task futures in the order they finish, not the order you submitted them.
- Use it to start processing results as soon as they’re ready (great for streaming fan-out/fan-in).
Key behavior
- Order: Completion order, not submission order.
- Timeouts: If you pass timeout=..., and no additional futures complete within that window, it raises TimeoutError. Futures that completed before the timeout are already yielded.
- Failures: It yields futures regardless of state (Completed, Failed, Cancelled). Calling future.result() on a failed future raises unless you pass raise_on_failure=False.
- Multiple uses: You can call as_completed on the same set of futures multiple times; each call creates a new generator. Completed futures will be yielded immediately.
- Duplicates: If the same future appears more than once in the input list, it’s deduplicated internally.
- Early exit: You can break out of the loop anytime. It doesn’t affect the running tasks.
Signature
from prefect.futures import as_completed
for f in as_completed(futures, timeout=None):
...
Common patterns
1) Stream results as they finish (handle success/failure)
from prefect.futures import as_completed
futures = search_api.map(queries) # returns a PrefectFutureList
for fut in as_completed(futures):
try:
result = fut.result() # may raise if task failed
except Exception as exc:
# Handle a failed or cancelled task
print(f"Search failed: {exc}")
continue
# Process the successful result
handle(result)
2) With timeout and partial progress
from prefect.futures import as_completed
from concurrent.futures import TimeoutError # Python’s TimeoutError
processed = 0
try:
for fut in as_completed(futures, timeout=10):
value = fut.result()
handle(value)
processed += 1
except TimeoutError:
print(f"Timed out after processing {processed} results.")
3) Streaming a second mapping based on each completed future
This is how you start processing each returned list as soon as it’s ready:
from prefect import flow, task
from prefect.futures import as_completed
@task
def search_api(q) -> list[dict]:
...
@task
def process_record(rec: dict):
...
@flow
def pipeline(queries: list[str]):
search_futures = search_api.map(queries)
processed_futures = []
for fut in as_completed(search_futures):
records = fut.result() # the list from that specific search
processed_futures.extend(process_record.map(records))
# Optionally wait for all processing to finish
return [pf.result() for pf in processed_futures]
When to choose as_completed vs wait()
- Use as_completed when you want a streaming pipeline (react to work finishing).
- Use wait(futures) when you want a snapshot: split into done/not_done at a moment in time, then decide what to do next.
Docs and references
- API reference (futures): prefect.futures
- Concurrency patterns: Run work concurrently
If you share:
- about how many futures (tasks) you expect,
- whether you want to stop on first error or keep going,
- and if you need a global timeout for the whole stage,
I can tailor an example that matches your exact use case.Tanya Hristova
10/31/2025, 12:41 PMMarvin
10/31/2025, 12:41 PMMarvin
10/31/2025, 12:42 PMfrom prefect import flow, task
from prefect.futures import as_completed
from prefect.task_runners import ThreadPoolTaskRunner
@task
def search_api(query: str) -> list[dict]:
# call external API
...
@task
def process_record(rec: dict) -> dict:
# post-process one record
...
@flow(task_runner=ThreadPoolTaskRunner(max_workers=20))
def pipeline(queries: list[str]):
# Fan-out: run all searches concurrently
search_futs = search_api.map(queries)
processed_futs = []
# Streaming fan-in: as each search completes, immediately process its records
for fut in as_completed(search_futs):
records = fut.result() # this search’s list
processed_futs.extend(process_record.map(records))
# Optionally wait for all processing to finish (true fan-in)
return [pf.result() for pf in processed_futs]
Notes
- as_completed yields futures in completion order, not submission order.
- You can combine this with rate limits and retries on the API task.
- If you want to persist or aggregate results incrementally (to a DB, S3, etc.), call that in the loop so slow searches don’t block fast ones.
Good references
- Concurrent execution patterns: Run work concurrently
- Futures API: prefect.futures
If you share your expected number of searches/records and any API rate limits, I can sketch a tuned version with proper concurrency caps and backoff.Tanya Hristova
11/04/2025, 8:53 AMMarvin
11/04/2025, 8:53 AMMarvin
11/04/2025, 9:01 AMfrom prefect import flow, task, unmapped, tags
from prefect.tasks import exponential_backoff
@task
def process_record(rec: dict) -> dict:
# pure transform, no DB IO
# ... do CPU/light IO work ...
return {"id": rec["id"], "value": rec["value"] * 2}
@task(
retries=3,
retry_delay_seconds=exponential_backoff(2),
)
def write_batch(processed: list[dict]):
# Use your DB client (SQLAlchemy example shown)
from sqlalchemy import create_engine, text
engine = create_engine("<postgresql+psycopg2://user:pass@host:5432/db>")
# single transaction for the whole batch write
with engine.begin() as conn:
# Example bulk upsert — adapt to your dialect
stmt = text("""
INSERT INTO my_table (id, value)
VALUES (:id, :value)
ON CONFLICT (id) DO UPDATE SET value = EXCLUDED.value
""")
conn.execute(stmt, processed)
@flow
def process_and_write(records: list[dict]):
processed_futures = process_record.map(records)
# Optional: limit concurrency on the write using a tag
with tags("db_write"):
write_batch(processed_futures)
# process_and_write(records) # call with your batch
Example 2 — Chunk first to control memory, then single batch write
from prefect import flow, task, tags
from prefect.utilities.collections import batched_iterable
from prefect.tasks import exponential_backoff
@task
def process_chunk(chunk: list[dict]) -> list[dict]:
return [{"id": r["id"], "value": r["value"] * 2} for r in chunk]
@task(
retries=3,
retry_delay_seconds=exponential_backoff(2),
)
def write_batch(all_processed: list[list[dict]]):
# flatten
flat = [item for sub in all_processed for item in sub]
# write once, as above...
# with engine.begin() as conn: conn.execute(..., flat)
@flow
def process_and_write_chunked(records: list[dict], batch_size: int = 5_000):
chunks = [list(c) for c in batched_iterable(records, batch_size)]
processed_per_chunk = process_chunk.map(chunks)
with tags("db_write"):
write_batch(processed_per_chunk)
Operational tips
- Concurrency limits: If multiple flow runs may hit your DB, use tag-based concurrency limits on the “db_write” tag to cap concurrent batch writes. See Tag-based concurrency limits and Global concurrency limits.
- Retries and backoff: Configure retries on the batch write task for transient DB errors. Docs: Retries.Marvin
11/04/2025, 9:01 AMTanya Hristova
11/04/2025, 9:29 AMMarvin
11/04/2025, 9:29 AMMarvin
11/04/2025, 9:31 AMMarvin
11/04/2025, 9:31 AMTanya Hristova
11/05/2025, 11:04 AMMarvin
11/05/2025, 11:04 AMMarvin
11/05/2025, 11:06 AMrequests.Session per process/thread with a bigger pool and pool_block=True (so callers wait instead of oversubscribing).
- Make sure you fully consume/close responses so connections return to the pool.
import threading
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
_thread_local = threading.local()
def get_session():
if not hasattr(_thread_local, "session"):
s = requests.Session()
retry = Retry(
total=3,
backoff_factor=0.5,
status_forcelist=[429, 500, 502, 503, 504],
allowed_methods=["GET", "POST", "PUT", "DELETE", "PATCH"],
)
adapter = HTTPAdapter(
pool_connections=100, # total pools
pool_maxsize=100, # per-host concurrent keep-alive connections
max_retries=retry,
pool_block=True, # block instead of creating/discarding extras
)
s.mount("http://", adapter)
s.mount("https://", adapter)
_thread_local.session = s
return _thread_local.session
def http_get(url, **kwargs):
session = get_session()
# Ensure the response is closed or fully read so the connection is returned to the pool
with session.get(url, timeout=30, **kwargs) as r:
r.raise_for_status()
return r.json()
Notes:
- Don’t create a new Session per request; reuse the configured one.
- If you use stream=True, read to EOF and close, or the connection won’t be returned to the pool.
2) Reduce concurrency from Prefect
- If lots of tasks call the same host concurrently, cap them with a tag-based concurrency limit so you don’t exceed your pool.
- CLI (verified):
prefect concurrency-limit create http_outbound 20
- Then tag the tasks that make HTTP calls:
from prefect import task, tags
@task
def fetch(url):
return http_get(url)
# when calling the task
with tags("http_outbound"):
fetch.map(urls)
- Docs: Tag-based concurrency limits and Global concurrency limits.
You can also limit local parallelism:
from prefect import flow
from prefect.task_runners import ThreadPoolTaskRunner
@flow(task_runner=ThreadPoolTaskRunner(max_workers=20))
def my_flow(...):
...
3) Match pool sizing to reality
- If your upstream or networking stack limits connections (e.g., 50 per client), set pool_maxsize and Prefect concurrency to stay within that.
- If you’re running in multiple pods/workers, remember the limit applies per process; multiply accordingly.
If you’re using an async client instead of requests:
- httpx: configure limits with httpx.Limits(max_keepalive_connections=..., max_connections=...) and reuse a single Client.
- aiohttp: reuse a ClientSession with a TCPConnector(limit=..., limit_per_host=...).
If you share a bit about how you’re calling that 172.19.8.47 endpoint (requests vs httpx/aiohttp, per-task or shared session), I can tailor the exact code pattern.Tanya Hristova
11/05/2025, 11:26 AMMarvin
11/05/2025, 11:26 AMMarvin
11/05/2025, 11:27 AMMarvin
11/05/2025, 11:27 AMTanya Hristova
11/12/2025, 9:53 AMMarvin
11/12/2025, 9:53 AMTanya Hristova
11/12/2025, 10:02 AMMarvin
11/12/2025, 10:02 AMMarvin
11/12/2025, 10:04 AMprefect work-pool create my-process-pool --type process
prefect worker start --pool my-process-pool
2) Initialize a project and define your deployment’s pull steps
prefect init --name my-project
Edit prefect.yaml so your deployment downloads and unpacks the artifact, then points to the flow entrypoint. Example for an S3-hosted zip:
name: my-project
prefect-version: ">=3.0.0"
deployments:
- name: etl-prod
description: Runs ETL from a pre-built artifact
entrypoint: app/flows/etl.py:etl # module path inside your artifact
work_pool:
name: my-process-pool
pull:
- prefect.deployments.steps.pull.pull_from_remote_storage:
# Use a stable alias like "latest.zip" that CI updates each build
url: "<s3://my-bucket/artifacts/myapp/latest.zip>"
target: "."
- prefect.deployments.steps.utility.run_shell_script:
# Adjust unzip/tar command to match your artifact
script: |
set -e
unzip -o latest.zip -d .
rm -f latest.zip
- prefect.deployments.steps.pull.set_working_directory:
directory: . # or the folder created by extraction (e.g., ./app)
3) Deploy
prefect deploy ./app/flows/etl.py:etl --name etl-prod
At runtime, the process worker will:
- Download your artifact
- Extract it
- Set the working directory
- Import and run the flow via your entrypoint
Notes
- Ensure the worker environment has your flow’s dependencies installed ahead of time. With a process worker, Prefect does not create an isolated environment by default.
- If using S3/GCS/HTTP, the worker must have credentials available (e.g., AWS env vars) so it can read the artifact URL.
- If your artifact is a directory tree instead of a zip, you can skip the unzip step and just use pull_from_remote_storage + set_working_directory.
Option B: Deploy via Python using remote source (no custom YAML)
If you store your flow code in remote storage as plain files (not zipped), Prefect can automatically configure the pull:
from prefect import flow
@flow
def etl():
...
# The 'source' can be an s3://, gs://, or other fsspec URL that points to your code root
etl.from_source(
source="<s3://my-bucket/myapp/code/>", # a folder containing your code
entrypoint="app/flows/etl.py:etl"
).deploy(
name="etl-prod",
work_pool_name="my-process-pool",
)
This creates a deployment that will pull from the remote source at runtime without you managing custom pull steps.
Key docs
- Prefect YAML and pull steps: prefect.yaml
- Store flow code remotely: Store flow code for deployments
- Deploy via Python: Deploy via Python API
- Pull steps API: prefect.deployments.steps.pull
- Process worker reference: prefect.workers.process
Quick checklist
- Artifact built and uploaded by CI to a stable URL
- Worker host has Python + dependencies installedMarvin
11/12/2025, 10:04 AMMarvin
11/12/2025, 10:18 AM# prefect-version is optional; shown for clarity
prefect-version: "3.*"
name: prebuilt-artifact-project
deployments:
- name: etl-prod
entrypoint: "flows/etl.py:etl" # Path inside the extracted archive
work_pool:
name: process-pool
pull:
# 1) Download the pre-built artifact to the work directory
- prefect.deployments.steps.pull.pull_from_remote_storage:
url: "<s3://my-bucket/artifacts/etl-build-{{> env.BUILD_ID }}.zip"
# 2) Extract it (adjust commands for tar.gz if needed)
- prefect.deployments.steps.utility.run_shell_script:
script: |
rm -rf app && mkdir -p app
unzip -q etl-build-*.zip -d app
# 3) Set working directory to the extracted project
- prefect.deployments.steps.pull.set_working_directory:
directory: "app"
# 4) Optionally install requirements from the artifact
- prefect.deployments.steps.utility.pip_install_requirements:
requirements_file: "requirements.txt"
2) Wheel (install and import the flow from the package)
- Build a wheel of your code and store it in remote storage (or publish to a private index)
- Pull and pip install the wheel at runtime, then import your flow from the installed package
Example prefect.yaml:
prefect-version: "3.*"
name: wheel-artifact-project
deployments:
- name: etl-prod
# Import the flow from the installed package instead of a repo file path
entrypoint: "mypkg.flows:etl"
work_pool:
name: process-pool
pull:
# 1) Download your wheel
- prefect.deployments.steps.pull.pull_from_remote_storage:
url: "<s3://my-bucket/artifacts/mypkg-{{> env.BUILD_VERSION }}-py3-none-any.whl"
# 2) Install it into the worker's environment
- prefect.deployments.steps.utility.run_shell_script:
script: "python -m pip install --no-deps ./mypkg-*.whl"
Notes and tips
- Don’t add any git pull steps; only include the artifact pull/install steps so the worker never clones source.
- Process workers run in the host Python environment, so make sure the environment has a compatible Python version and any base dependencies. If you need isolation, consider Docker/Kubernetes workers instead.
- If your artifact is a tar.gz, swap the unzip step for tar, e.g. tar -xzf artifact.tar.gz -C app.
- Python can import directly from zip files if added to PYTHONPATH; if you prefer that, replace extraction with a step that sets PYTHONPATH in job_variables.env or via a shell script export.
How to create the pool and start the worker
# Create a process work pool (run once)
prefect work-pool create "process-pool" --type process
# Start a worker that polls your pool
prefect worker start --pool "process-pool"
How to deploy
- Ensure the prefect.yaml is in your project root, then:
prefect deploy
Or do it programmatically if you prefer:
from prefect import flow, Flow
# If you want to wire source from code instead of YAML
Flow.from_source(
"<s3://my-bucket/artifacts/etl-build-123.zip>",
entrypoint="flows/etl.py:etl",
).deploy(
name="etl-prod",
work_pool_name="process-pool",
)
If you share:
- where your artifact is stored (S3/GCS/HTTP/other),
- its format (zip/tar/wheel), and
- how you’d like dependencies handled,
I can tailor the exact pull steps and entrypoint for your setup.Tanya Hristova
11/12/2025, 2:41 PM# Initialize Timocom instance
timocom_instance = Timocom(logger, pg_utils, user, password)
# Create search tasks
search_tasks = timocom_instance.create_search_tasks(sanitized_search_parameters)
search_futures = timocom_instance.search.map(search_tasks)
# Initialize Freight Request processor instance
config = get_config(
ors_server_variable,
price_calculation_server_variable,
predictive_scoring_server_variable,
here_maps_api_key_variable,
)
processor_instance = FreightRequestProcessor(config, logger)
# Initialize Freight Request repository instance
repository_instance = FreightRequestRepository(logger, pg_utils)
# As each search finishes, map the processing over its returned list
for f in as_completed(search_futures):
freight_requests = f.result() # this is the list from that search
processed = processor_instance.process.map(freight_requests)
repository_instance.save_requests_pg.submit(processed.result())
publish_refresh_message(
rabbitmq_secret_block, search_parameters["tab_id"], search_parameters["job_id"], "REFRESH"
)Marvin
11/12/2025, 2:42 PMMarvin
11/12/2025, 2:45 PMfreight_requests = f.result() blocks the client until that whole search finishes and transfers the full list into memory.
- processed.result() forces all processing to complete before you schedule the save — this removes most concurrency.
- Saving one big blob synchronously:
- repository_instance.save_requests_pg.submit(processed.result()) waits for processing to finish, then performs a single large write. This can create head‑of‑line blocking and spikes in memory/DB load.
- Too many small writes or too large single writes:
- If you switch to per-item saves, you get many small round trips. If you save the whole thing at once, you get a single heavy transaction. Batching is faster and more stable.
- Mixed I/O and CPU with a single execution model:
- Search and DB are I/O-bound; processing may be CPU-bound. Using the same execution strategy for both can underperform.
- Per-item side effects:
- Publishing messages on the hot path can block. Make it asynchronous and/or batch where possible.
- Hidden overheads:
- Recreating clients or not reusing HTTP/DB connections inside tasks; excessive logging; unbounded concurrency against remote APIs.
A faster pattern (no blocking, dynamic mapping, batched writes)
Key changes:
- Don’t call .result() in the loop — keep everything as futures.
- Use dynamic mapping: map over a future that resolves to a list; Prefect will fan out at runtime.
- Batch for DB writes.
- Make the message publish a task and submit it so it doesn’t block.
Example
```
from prefect import flow, task
from prefect.futures import as_completed
from prefect.task_runners import ThreadPoolTaskRunner
# Utility: chunk a list into batches
@task
def chunk(items: list, size: int = 500) -> list[list]:
return [items[i:i+size] for i in range(0, len(items), size)]
# Make publish async so it doesn't block the loop
@task
def publish_refresh(rabbitmq_secret_block, tab_id, job_id, status="REFRESH"):
publish_refresh_message(rabbitmq_secret_block, tab_id, job_id, status)
@flow(name="timocom-etl", task_runner=ThreadPoolTaskRunner(max_workers=32))
def run_etl(
logger,
pg_utils,
user,
password,
sanitized_search_parameters,
search_parameters,
ors_server_variable,
price_calculation_server_variable,
predictive_scoring_server_variable,
here_maps_api_key_variable,
rabbitmq_secret_block,
save_batch_size: int = 500,
):
# Initialize clients once
timocom_instance = Timocom(logger, pg_utils, user, password)
config = get_config(
ors_server_variable,
price_calculation_server_variable,
predictive_scoring_server_variable,
here_maps_api_key_variable,
)
processor_instance = FreightRequestProcessor(config, logger)
repository_instance = FreightRequestRepository(logger, pg_utils)
# Kick off searches concurrently
search_tasks = timocom_instance.create_search_tasks(sanitized_search_parameters)
search_futures = timocom_instance.search.map(search_tasks) # list[Future[list]]
# Stream results as searches finish — no .result() here
for search_future in as_completed(search_futures):
# Dynamically fan out processing over the list returned by search_future
processed = processor_instance.process.map(search_future) # list[Future[item]]
# Option A: Save per item (simple, but more round trips)
# repository_instance.save_requests_pg.map(processed)
# Option B (faster/stabler): batch saves
processed_list = processed.gather() # Future[list[item]]
batches = chunk(processed_list, save_batch_size) # Future[list[list[item]]]
repository_instance.save_requests_pg.map(batches) # batch DB writes in parallel
# Publish without blockingMarvin
11/12/2025, 2:45 PMEven faster for CPU-heavy processing
If `processor_instance.process` is CPU-bound (e.g., heavy scoring), run that part with a process pool for true parallelism, while keeping search/DB on threads.
- Wrap CPU processing in a subflow with ProcessPoolTaskRunner.
- Submit that subflow per search batch.
Sketch:
from prefect import flow
from prefect.task_runners import ProcessPoolTaskRunner
@flow(task_runner=ProcessPoolTaskRunner(max_workers=8))
def process_batch(processor_instance, items: list):
# Map across items in processes
return processor_instance.process.map(items).gather()
@flow(task_runner=ThreadPoolTaskRunner(max_workers=32))
def main(...):
...
for search_future in as_completed(search_futures):
processed_list = process_batch.submit(processor_instance, search_future) # Future[list]
batches = chunk(processed_list, 500)
repository_instance.save_requests_pg.map(batches)
publish_refresh.submit(...)
```
Tuning tips
- Batch size: 200–1000 is a good starting range for DB upserts; use COPY/execute_batch/execute_values where possible inside save_requests_pg.
- Concurrency:
- ThreadPoolTaskRunner max_workers: tune based on I/O latency and API limits (8–64).
- ProcessPoolTaskRunner max_workers: number of CPU cores for CPU-heavy steps.
- External services: reuse HTTP sessions/DB connections inside tasks; set timeouts; add backoff; apply concurrency limits to avoid rate limiting.
- Logging: reduce per-item logging; log per batch.
- Memory: avoid holding entire datasets on the client; keep lists as futures and operate on them in tasks/subflows.
If you share:
- Typical sizes (search count, items per search),
- Whether process is CPU or I/O-bound,
- How save_requests_pg writes (single insert vs bulk),
I can tailor batch sizes, worker counts, and a concrete save_requests_pg bulk-insert pattern to squeeze more throughput.Tanya Hristova
11/12/2025, 3:38 PMMarvin
11/12/2025, 3:38 PMMarvin
11/12/2025, 3:40 PM.result() inside the flow unless you intend to block; use .gather() to collapse lists of futures into a single future-of-list, and let Prefect schedule downstream tasks.
- Choose the right save pattern:
- If your DB task saves one item: map over processed items.
- If it saves a batch: gather, then chunk, then map over batches.
Fix 1: If your save task expects a batch (list of items)
from prefect import task
from prefect.futures import as_completed
@task
def chunk(items: list, size: int = 500) -> list[list]:
return [items[i:i+size] for i in range(0, len(items), size)]
for f in as_completed(search_futures):
# Dynamically map processing over the list returned by f
processed = processor_instance.process.map(f) # list[Future[item]]
# Collapse to a single future-of-list without blocking the flow
processed_list = processed.gather() # Future[list[item]]
# Optional: batch to stabilize DB load
batches = chunk.submit(processed_list, size=500) # Future[list[list[item]]]
# Map DB writes over batches; Prefect waits for each batch future before running
repository_instance.save_requests_pg.map(batches)
# Publish without blocking
publish_refresh_message(
rabbitmq_secret_block,
search_parameters["tab_id"],
search_parameters["job_id"],
"REFRESH",
)
Fix 2: If your save task expects a single item
for f in as_completed(search_futures):
processed = processor_instance.process.map(f) # list[Future[item]]
repository_instance.save_requests_pg.map(processed)
Fix 3: If you must “save all processed items from this search” in one call
You can submit the save task with a future-of-list directly; Prefect will wait for it:
for f in as_completed(search_futures):
processed = processor_instance.process.map(f) # list[Future[item]]
processed_list = processed.gather() # Future[list[item]]
repository_instance.save_requests_pg.submit(processed_list)
What to double-check
- save_requests_pg is a Prefect task (@task) and you’re calling it with .submit(...) or .map(...) (not as a plain function).
- Any helper like chunk that takes futures must also be a Prefect task, or you must call .result() before invoking a plain Python function.
- Look for upstream failures in logs; Prefect will cancel downstream work if an upstream task failed. The “Execution was cancelled by the runtime environment” message often indicates upstream failure or the worker shutting down before the task started.
Optional hardening
- Add retries/timeouts to the DB task to mitigate transient DB issues:
@task(retries=3, retry_delay_seconds=5, timeout_seconds=60)
def save_requests_pg(batch: list[dict]):
...
- If processing is CPU-heavy, run processing with a ProcessPoolTaskRunner and keep DB I/O on threads.
If you can share the save_requests_pg task signature (expects one item vs batch) and a small snippet of the current loop, I’ll tailor the exact wiring to eliminate the cancellation.Tanya Hristova
11/12/2025, 3:43 PMsave_requests_pg expects a batch of data but I don't need to further make chinks of it - the result from
processed = processor_instance.process.map(search_future) is already chunked.Marvin
11/12/2025, 3:43 PMMarvin
11/12/2025, 3:45 PMsave_requests_pg expects a batch and your processed = processor_instance.process.map(search_future) already yields batches, the simplest fix is to stop resolving futures and just map the save across those batch futures. Prefect will wait for each processed batch before running the corresponding DB write.
Do this:
for search_future in as_completed(search_futures):
# Each element of `search_future` is already a chunk
processed_batches = processor_instance.process.map(search_future) # list[Future[Batch]]
# Write each processed batch as it becomes ready (no .result(), no gather)
repository_instance.save_requests_pg.map(processed_batches)
# Optionally publish without blocking the flow
# (wrap as a @task and submit if you want it in the Prefect graph)
publish_refresh_message(
rabbitmq_secret_block,
search_parameters["tab_id"],
search_parameters["job_id"],
"REFRESH",
)
Important checks
- Ensure save_requests_pg is a Prefect task (decorated with @task) and you’re calling it with .map(...) or .submit(...).
- Don’t pass Prefect futures into plain Python functions. If you must use a helper that isn’t a task, resolve its input first (or convert that helper into a task).
- That “Execution was cancelled by the runtime environment.” message can also occur if an upstream task failed; scan logs for any failures in the process tasks or earlier searches.
If instead your process is item-level (not batch-level), you’ll want to gather to form one batch per search before saving:
for search_future in as_completed(search_futures):
processed_items = processor_instance.process.map(search_future) # list[Future[item]]
batch_future = processed_items.gather() # Future[list[item]]
repository_instance.save_requests_pg.submit(batch_future) # single batched write
Tell me which of those two shapes you have (per-chunk vs per-item process), and I’ll tailor the final snippet 1:1 with your signatures.Tanya Hristova
11/12/2025, 3:55 PMMarvin
11/12/2025, 3:55 PMMarvin
11/12/2025, 3:56 PMfrom prefect import task
from prefect.futures import as_completed
@task
def publish_refresh(rabbitmq_secret_block, tab_id, job_id, status="REFRESH", _after=None):
# `_after` is unused; it just enforces ordering
publish_refresh_message(rabbitmq_secret_block, tab_id, job_id, status)
for search_future in as_completed(search_futures):
# process returns a single batch for this search
processed_batch = processor_instance.process.submit(search_future) # Future[Batch]
# save expects a batch
save_future = repository_instance.save_requests_pg.submit(processed_batch)
# Option A (works in all cases): pass save_future as a dummy argument to enforce ordering
publish_refresh.submit(
rabbitmq_secret_block,
search_parameters["tab_id"],
search_parameters["job_id"],
"REFRESH",
_after=save_future, # ensures publish runs only after save completes successfully
)
# Option B (if you prefer and your version supports it):
# publish_refresh.submit(
# rabbitmq_secret_block,
# search_parameters["tab_id"],
# search_parameters["job_id"],
# "REFRESH",
# wait_for=[save_future],
# )
If you can’t make publish_refresh a Prefect task and must call the plain function, block on the save future first:
save_future = repository_instance.save_requests_pg.submit(processed_batch)
save_future.wait() # or .result()
publish_refresh_message(rabbitmq_secret_block, tab_id, job_id, "REFRESH")
Notes
- Passing a Prefect future as an argument to another Prefect task automatically creates a dependency; Prefect will run the downstream task only after the upstream completes successfully.
- Using the dummy _after arg keeps publish from running concurrently with the DB write without changing your publish logic.Tanya Hristova
11/12/2025, 4:02 PMfor search_future in as_completed(search_futures):
<http://logger.info|logger.info>(f"Start processing: {datetime.now()}")
processed_batch = processor_instance.process.map(search_future)
save_future = repository_instance.save_requests_pg.submit(processed_batch)
<http://logger.info|logger.info>("Requests data persistence completed.")
<http://logger.info|logger.info>(f"Publish message: {datetime.now()}")
publish_refresh_message.submit(
rabbitmq_secret_block,
search_parameters["tab_id"],
search_parameters["job_id"],
"REFRESH",
_after=save_future,
)
search future returns a list of items
the process() method gets a single item as argument, so I need map
then save_requests_pg() method gets a list of items, so I need a submit
Am I right?Marvin
11/12/2025, 4:02 PMMarvin
11/12/2025, 4:04 PMprocess.map(search_future) returns a list of futures (one per item) but save_requests_pg expects a single batch (list of items), you should gather those item futures into a single future-of-list before submitting the save. Then make publish depend on the save future.
Key points
- Map over items: process.map(search_future) -> list[Future[item]]
- Gather into one batch: processed_items.gather() -> Future[list[item]]
- Submit save with the batch future: Prefect will resolve the batch before running
- Enforce “publish after save” by making publish a Prefect task that takes the save future as a dummy arg (or a wait parameter)
Drop-in pattern
for search_future in as_completed(search_futures):
<http://logger.info|logger.info>(f"Start processing: {datetime.now()}")
# 1) Process each item (mapping yields list of futures)
processed_items = processor_instance.process.map(search_future) # list[Future[item]]
# 2) Collapse to a single future-of-list (non-blocking)
processed_batch = processed_items.gather() # Future[list[item]]
# 3) Save expects a batch (list of items)
save_future = repository_instance.save_requests_pg.submit(processed_batch)
<http://logger.info|logger.info>("Requests data persistence scheduled.") # scheduled, not completed yet
# 4) Publish only after save finishes
# Ensure publish_refresh_message is a @task that accepts an extra arg to enforce ordering:
# def publish_refresh_message(..., _after=None): ...
publish_refresh_message.submit(
rabbitmq_secret_block,
search_parameters["tab_id"],
search_parameters["job_id"],
"REFRESH",
_after=save_future, # forces dependency without blocking the flow runner
)
Notes
- Don’t pass a raw list[Future] to `save_requests_pg.submit`; either gather (as above) or change save_requests_pg to accept per-item and use map. Since you want a single batch, gather is the right choice.
- The _after=save_future approach requires publish_refresh_message to accept an _after parameter (unused). Alternatively, pass save_future as a dummy argument the task already accepts, or create a small wrapper task that accepts wait_for: Any | None = None.
- If you truly need the “completed” log after the save finishes, make that log a tiny Prefect task that depends on save_future, rather than logging immediately in the flow code.Tanya Hristova
11/12/2025, 4:09 PMMarvin
11/12/2025, 4:09 PMMarvin
11/12/2025, 4:11 PMPrefectFutureList doesn’t have a .gather() method in Prefect 3.x. You don’t need it here anyway.
Since:
- search_future -> list[Item]
- process.map(search_future) -> list[Future[ProcessedItem]] (already item-level)
- save_requests_pg expects a batch list[ProcessedItem]
You can pass the list of futures directly to the save task. Prefect will resolve nested futures in task inputs before running the task.
Drop-in fix (keep map, then submit, and publish only after save):
for search_future in as_completed(search_futures):
<http://logger.info|logger.info>(f"Start processing: {datetime.now()}")
# Map over items -> list of futures
processed_items = processor_instance.process.map(search_future) # PrefectFutureList[ProcessedItem]
# Pass the future list directly; Prefect resolves it to list[ProcessedItem] at runtime
save_future = repository_instance.save_requests_pg.submit(processed_items)
<http://logger.info|logger.info>("Requests data persistence scheduled.")
<http://logger.info|logger.info>(f"Publish message: {datetime.now()}")
# Ensure publish runs after save completes
# Option 1: dummy arg to enforce dependency (preferred if your task accepts it)
publish_refresh_message.submit(
rabbitmq_secret_block,
search_parameters["tab_id"],
search_parameters["job_id"],
"REFRESH",
_after=save_future, # unused param to enforce dependency
)
# Option 2: if your version supports it, you can also do:
# publish_refresh_message.submit(..., wait_for=[save_future])
Alternative if you really want a single “future-of-list” (gather semantics) before saving:
from prefect import task
@task
def collect(items):
# items arrives as list[ProcessedItem] since Prefect resolves futures
return items
processed_items = processor_instance.process.map(search_future) # list[Future[item]]
batch_future = collect.submit(processed_items) # Future[list[item]]
save_future = repository_instance.save_requests_pg.submit(batch_future)
publish_refresh_message.submit(..., _after=save_future)
Key points
- Passing a list of futures into a Prefect task is valid; Prefect resolves them to concrete values before executing the task.
- Remove `.gather()`; it’s not needed and causes the AttributeError you saw.
- Make sure publish_refresh_message is a Prefect task. If it’s a plain function, block after save with save_future.result() before calling it.Tanya Hristova
11/12/2025, 4:13 PMMarvin
11/12/2025, 4:13 PMMarvin
11/12/2025, 4:14 PMfrom prefect import task
from prefect.futures import as_completed
# 1) Collect list[Future[item]] -> Future[list[item]]
@task
def collect_batch(items):
# Prefect resolves futures in inputs; you receive concrete items here
return items
# 2) Make publish a task so we can depend on the save
@task
def publish_refresh(rabbitmq_secret_block, tab_id, job_id, status="REFRESH", _after=None):
publish_refresh_message(rabbitmq_secret_block, tab_id, job_id, status)
for search_future in as_completed(search_futures):
<http://logger.info|logger.info>(f"Start processing: {datetime.now()}")
# Map per item -> list[Future[processed_item]]
processed_items = processor_instance.process.map(search_future)
# Collapse to a single future-of-list for the batch
processed_batch = collect_batch.submit(processed_items) # Future[list[processed_item]]
# Save expects a batch
save_future = repository_instance.save_requests_pg.submit(processed_batch)
<http://logger.info|logger.info>("Requests data persistence scheduled.")
<http://logger.info|logger.info>(f"Publish message: {datetime.now()}")
# Publish strictly after save completes
publish_refresh.submit(
rabbitmq_secret_block,
search_parameters["tab_id"],
search_parameters["job_id"],
"REFRESH",
_after=save_future, # unused param to enforce dependency
)
Why this fixes the crash
- We avoid passing a PrefectFutureList directly into the save task; instead, collect_batch produces a single Future[list[processed_item]] that is safe to pass to save_requests_pg.submit.
- Dependencies are explicit: publish waits on save via _after=save_future.
- No blocking in the flow code; Prefect schedules each step when its upstreams finish.
If you don’t want a separate collector task
- You can change the DB task signature to accept an Iterable and rely on Prefect to resolve inputs, but in practice the collector pattern is the most reliable when mixing map and submit.
Sanity checks
- Ensure processor_instance.process, repository_instance.save_requests_pg, and publish_refresh are Prefect tasks (decorated with @task) — not plain functions.
- If publish must remain a plain function, call it only after blocking for the save to complete:
save_future = repository_instance.save_requests_pg.submit(processed_batch)
save_future.result() # waits for DB write
publish_refresh_message(...)
- If you still see “Execution was cancelled by the runtime environment.”, check the upstream process task logs for failures; Prefect cancels downstream work when an upstream fails or if the worker process shuts down.
If you can share the task decorators/signatures for process, save_requests_pg, and publish_refresh_message, I can double-check the exact wiring.Tanya Hristova
11/13/2025, 6:33 PMMarvin
11/13/2025, 6:33 PMMarvin
11/13/2025, 6:36 PMconcurrent.futures when an executor has been closed and you call submit() again.
In Prefect 3.x, this usually means the flow’s task runner (e.g., ThreadPoolTaskRunner) has already shut down when something tries to schedule another task. The task runner is started when the flow begins and is shut down as the flow exits; after that point, no new tasks can be submitted.
Common triggers in Prefect:
- The flow returns, then code tries to submit more tasks afterward (e.g., in cleanup code or background threads).
- Futures are still being used to trigger new work after the flow has ended.
- Asynchronous or background callbacks fire late and try to schedule additional tasks after runner shutdown.
- Re-using objects tied to a previous flow run’s task runner.
How to fix:
- Only call task.submit(...) while the flow is actively running (before the function returns).
- Resolve or wait on futures before the flow ends:
from prefect import flow, task
from prefect.futures import wait
@task
def work(i):
return i * i
@flow
def my_flow():
futs = [work.submit(i) for i in range(5)]
# Ensure all scheduled work completes before the flow exits
wait(futs) # or: results = [f.result() for f in futs]
- If you need to schedule more work “later,” wrap that logic in another flow/subflow that runs after the first flow completes rather than trying to submit tasks from outside a running flow.
- Avoid spawning background threads or callbacks that submit tasks after the flow finishes; make sure they complete inside the flow’s lifetime.
Useful references:
- Task runners in Prefect 3.x
- Running work concurrently
- Futures API (wait, as_completed, result)
If you can share a small snippet or the stack trace showing where the submit happens, I can point to the exact spot that’s firing after shutdown.Tanya Hristova
11/14/2025, 10:00 AMMarvin
11/14/2025, 10:00 AMMarvin
11/14/2025, 10:02 AMmap() API on tasks, and you generally shouldn’t orchestrate from inside a task. Do your “mapping” at the flow level by submitting tasks over an iterable.
If you meant Python’s built-in map, you can call it inside a task, but it will just run inline in that single task (no Prefect parallelism or visibility).
Recommended pattern (fan-out/fan-in) in a flow:
from prefect import flow, task, unmapped
from prefect.task_runners import ThreadPoolTaskRunner
@task
def process_record(record, constant):
return f"{record}-{constant}"
@task
def combine(results):
# You can pass futures; Prefect will resolve them for you
return ",".join(results)
@flow(task_runner=ThreadPoolTaskRunner())
def main(records):
# Fan-out: submit one task per record
futures = [process_record.submit(r, unmapped("same-for-all")) for r in records]
# Fan-in: downstream task can take the list of futures directly
combined = combine.submit(futures).result()
return combined
if __name__ == "__main__":
print(main(["a", "b", "c"]))
Notes
- Use unmapped(...) for arguments you don’t want to vary across the mapped tasks.
- For streaming results as they finish, you can iterate with prefect.futures.as_completed(...).
If you’re coming from Prefect 1.x’s task.map, that API was removed; submit + list comprehensions/loops is the 2.x/3.x replacement. If you need nested orchestration, consider promoting the inner logic to a subflow instead of calling submit inside a task.
Helpful docs:
- Task runners (controls concurrency): Task runners
- Futures utilities (e.g., as_completed): Futures API
- unmapped annotation: unmapped
If you have a snippet you’re trying to convert to Prefect 3.x, paste it here and I’ll help adapt it.Tanya Hristova
11/20/2025, 10:04 AMMarvin
11/20/2025, 10:04 AMMarvin
11/20/2025, 10:07 AMfrom prefect import flow, task
@task
def produce(batch_id: int) -> list[int]:
# Example: each run returns a list with some overlap
return [batch_id, batch_id + 1, 42]
@task
def dedupe_all(lists_of_items: list[list[int]]) -> list[int]:
unique = set()
for lst in lists_of_items:
unique.update(lst)
return sorted(unique)
@flow
def main():
# Run N producers in parallel
futures = produce.map([1, 2, 3, 4])
# Collect results from all mapped runs -> list[list[int]]
lists = futures.result()
# Reduce: flatten + dedupe
unique_items = dedupe_all(lists)
return unique_items
result = main()
Notes:
- task.map(...) returns a `PrefectFutureList`; call .result() to aggregate mapped outputs into a single list[...] you can pass downstream.
- If your producer can return a set directly, merging is even simpler:
- Task returns set[...]
- Merge with set.union(*sets) in the reducer task.
- If items are not hashable (e.g., dicts), dedupe by a key:
- Build a key (e.g., tuple of selected fields) and keep first-seen items.
Example for unhashable items:
@task
def dedupe_by_key(list_of_lists: list[list[dict]]) -> list[dict]:
seen = set()
out = []
for lst in list_of_lists:
for item in lst:
key = (item["id"], item.get("type"))
if key not in seen:
seen.add(key)
out.append(item)
return out
This pattern gives you a clean fan-out (map) → fan-in (reduce) pipeline without any extra synchronization primitives.