<@ULVA73B9P> is it safe to store api keys on Prefe...
# marvin-ai
g
@Marvin is it safe to store api keys on Prefect Variables? Also why other people can see what i ask you? 😿
m
✅ thought for 182.7 seconds
Short answer: No. Don’t store API keys in Prefect Variables. - In Prefect 3.x, Variables are for non-sensitive config only. They’re plain strings and visible to anyone with read access to your workspace. Variables docs - For secrets, use a Secret block (or an external secret manager). Secret block values are encrypted at rest and retrieved only when needed. - Prefect Cloud: secrets are encrypted and stored in a separate, tightly controlled project. Security • Shared responsibility model - Prefect OSS: secrets are encrypted at rest by the server. How to store secrets • Blocks concept Example (create and use a Secret block):
Copy code
from prefect.blocks.system import Secret

# Create and save once (e.g., in a setup script or REPL)
Secret(value="YOUR_API_KEY").save("my-api-key")

# Load and use in your flow
from prefect import flow
from prefect.blocks.system import Secret

@flow
def my_flow():
    api_key = Secret.load("my-api-key").get()
    # use api_key safely here

if __name__ == "__main__":
    my_flow()
Prefer external managers when you can (AWS Secrets Manager, GCP Secret Manager, Vault) via Prefect integrations: - prefect-aws - prefect-gcp - prefect-snowflake On “why other people can see what I ask you?” - If you’re chatting in a public Slack channel (like the Prefect Community Slack), everyone in that channel can see your messages. For privacy, send me a direct message or move to a private channel with only the people you want included. And regardless, never paste real secrets into Slack. If you’d like, tell me which variables you’re currently using for keys and I can help you migrate them to Secret blocks.
g
@Marvin Im getting an error trying to reset my database using this command: uv run prefect server database reset The error says: sqlalchemy.exc.IntegrityError: (sqlite3.IntegrityError) NOT NULL constraint failed: _alembic_tmp_task_run.flow_run_id [SQL: INSERT INTO _alembic_tmp_task_run (id, created, updated, name, state_type, run_count, expected_start_time, next_scheduled_start_time, start_time, end_time, total_run_time, task_key, dynamic_key, cache_key, cache_expiration, task_version, empirical_policy, task_inputs, tags, flow_run_id, state_id, state_name, state_timestamp, flow_run_run_count) SELECT task_run.id, task_run.created, task_run.updated, task_run.name, task_run.state_type, task_run.run_count, task_run.expected_start_time, task_run.next_scheduled_start_time, task_run.start_time, task_run.end_time, task_run.total_run_time, task_run.task_key, task_run.dynamic_key, task_run.cache_key, task_run.cache_expiration, task_run.task_version, task_run.empirical_policy, task_run.task_inputs, task_run.tags, task_run.flow_run_id, task_run.state_id, task_run.state_name, task_run.state_timestamp, task_run.flow_run_run_count FROM task_run] (Background on this error at: https://sqlalche.me/e/20/gkpj) An exception occurred.
m
✅ thought for 248.8 seconds
Thanks for the traceback — that’s an Alembic migration failing because there are existing
task_run
rows where
flow_run_id
is NULL, but the migration is trying to enforce
NOT NULL
on that column. This typically happens when an older DB/schema has inconsistent data and we run a newer migration. First, a couple quick checks: - Make sure no Prefect server/API process is running while you reset (stop any running
prefect server start
or API containers). - Confirm which DB you’re targeting and that you’re resetting the right one:
Copy code
uv run prefect config view --show-sources
Look for
server.database.connection_url
and
home
. If it’s SQLite, you’ll see a
sqlite+aiosqlite:///...
path to a .db file under
$PREFECT_HOME
(by default under your home directory). Ways to fix Option A — Nuke-and-recreate (SQLite and you don’t need the data) 1) Stop Prefect server/API. 2) Back up and delete the SQLite file reported by
server.database.connection_url
. 3) Recreate:
Copy code
uv run prefect server database reset -y
Then start the server again. Option B — Clean bad rows and retry (if you need to keep the rest of the data) - For SQLite (replace with the actual path you saw in config view):
Copy code
sqlite3 /path/to/your.db "DELETE FROM task_run WHERE flow_run_id IS NULL;"
- For Postgres (replace with your DSN/connection and schema if needed):
Copy code
psql "$PREFECT_SERVER_DATABASE_CONNECTION_URL" -c "DELETE FROM task_run WHERE flow_run_id IS NULL;"
Then:
Copy code
uv run prefect server database reset -y
Option C — Ensure the reset is targeting the right DB If you manage multiple environments, force the target explicitly:
Copy code
PREFECT_SERVER_DATABASE_CONNECTION_URL="sqlite+aiosqlite:///absolute/path/to/your.db" uv run prefect server database reset -y
If the error persists, please share: - The output of:
Copy code
uv run prefect version
uv run prefect config view --show-sources --show-defaults
- Whether you’re on SQLite or Postgres - Whether any Prefect server/API process was running when you ran the reset References: - Settings and profiles - Settings reference
g
@Marvin When i run the following command uv run prefect config view --show-sources i get these response: PREFECT_PROFILE='local' PREFECT_API_URL='http://127.0.0.1:4200/api' (from profile) So, no database path set. And yes i want to nuke the database. But what is the path to it? Yesterday i did many resets on the db while the server was running. Should i stop the server first?
m
✅ thought for 190.7 seconds
Short answers - Yes — stop the server before resetting or deleting the DB. Running resets while the server is running can leave the DB in a bad state. - By default, the local DB is a SQLite file under your Prefect home directory. If you haven’t customized anything, it’s typically at ~/.prefect/prefect.db Find the exact DB path - The DB URL is masked by default in config view. Show it explicitly:
Copy code
uv run prefect config view --show-sources --show-secrets | grep DATABASE_CONNECTION_URL
You should see something like:
Copy code
PREFECT_API_DATABASE_CONNECTION_URL='sqlite+aiosqlite:///.../.prefect/prefect.db'
Note: If you’re using Docker to run the server, that path lives inside the container’s filesystem unless you’ve mounted a volume. Nuke and recreate safely 1) Stop the server (Ctrl+C if it’s running in your terminal, or stop the process/container). 2) Delete the SQLite file shown by the command above (example for default):
Copy code
rm -f ~/.prefect/prefect.db
3) Recreate/reset the DB:
Copy code
uv run prefect server database reset -y
4) Start the server again:
Copy code
uv run prefect server start
If you’d rather not hunt for the file, you can force a fresh DB by explicitly setting the URL to a new path and running reset:
Copy code
PREFECT_API_DATABASE_CONNECTION_URL="sqlite+aiosqlite:///$HOME/.prefect/prefect.db" uv run prefect server database reset -y
If anything’s unclear (OS, Docker vs local, etc.), let me know and I’ll tailor the exact commands. References: - Settings and profiles: Docs - Settings reference (PREFECT_HOME, API DB settings): Reference
g
@Marvin I got this code from the 102 course of Prefect. I did everything that the instructor did but inspecting the UI i see that theres no second task in the chart, the second task is marked with status pending in the Task Run menu inside Runs. But when i click in the Task pending i get the log Finished in state Completed(). Im running locally
m
✅ thought for 116.6 seconds
Got it — a few things can cause that “pending in list, but logs say Completed” mismatch. Can you share: - Your Prefect version (run:
uv run prefect version
) - How you’re running the server (the exact command, e.g.,
uv run prefect server start
) - A minimal snippet of the flow/tasks from the 102 lesson In the meantime, here are the most common causes and fixes: 1) UI state is stale - The Task Runs table does not always auto-refresh. Hard refresh the page and toggle “Live updates” on the run page. - Make sure your Python session is pointed at the same API URL you’re viewing in the browser (
PREFECT_API_URL=<http://127.0.0.1:4200/api>
if you’re truly local). 2) Server was started without services or the server wasn’t restarted cleanly - Ensure you’re not using
--no-services
. - Stop the server completely, then start it again:
Copy code
# stop any running server
# then start it cleanly
uv run prefect server start
3) Local database got into a weird state (especially if you reset while the server was running) - Yes — stop the server first. Then nuke and recreate:
Copy code
# stop server first
uv run prefect config view --show-sources --show-secrets | grep DATABASE_CONNECTION_URL
# delete the sqlite file that command shows (typically ~/.prefect/prefect.db)
rm -f ~/.prefect/prefect.db
uv run prefect server database reset -y
uv run prefect server start
4) The second task wasn’t actually scheduled/executed (common call patterns) - In Prefect 3.x, either call tasks directly (sequential) or use
.submit()
and then
.result()
for concurrency. Examples: Direct calls (simple and correct):
Copy code
from prefect import flow, task

@task
def plus_one(x): 
    return x + 1

@task
def times_two(x): 
    return x * 2

@flow
def demo():
    a = plus_one(1)
    b = times_two(a)
    return b

if __name__ == "__main__":
    demo()
Concurrent pattern (don’t forget to resolve results):
Copy code
@flow
def demo_concurrent():
    a_f = plus_one.submit(1)
    b_f = times_two.submit(a_f)
    b = b_f.result()  # ensure completion
    return b
If you share your snippet, I can confirm the pattern. Also, since you’ve done DB resets while the server was running, I’d strongly recommend the “stop server → delete DB file → reset → start server” sequence above and then re-run the flow. Docs for reference: - Flows and tasks in Prefect 3.x: Docs
g
I forgot the code. This is my code: Finished in state Completed()
I forgot the code heres my code: from prefect import flow, task import httpx @task def fetch_weather(lat: float, lon: float): base_url = "https://api.open-meteo.com/v1/forecast/" temps = httpx.get( base_url, params=dict(latitude=lat, longitude=lon, hourly="temperature_2m"), ) forecasted_temp = float(temps.json()["hourly"]["temperature_2m"][0]) print(f"Forecasted temp C: {forecasted_temp} degrees") return forecasted_temp @task def save_weather(temp: float): with open("weather.csv", "w+") as w: w.write(str(temp)) return "Successfully wrote temp" @flow def pipeline(lat: float = 38.9, lon: float = -77.0): temp = fetch_weather(lat, lon) result = save_weather(temp) return result if name == "__main__": pipeline()
@Marvin I forgot the code heres my code: from prefect import flow, task import httpx @task def fetch_weather(lat: float, lon: float): base_url = "https://api.open-meteo.com/v1/forecast/" temps = httpx.get( base_url, params=dict(latitude=lat, longitude=lon, hourly="temperature_2m"), ) forecasted_temp = float(temps.json()["hourly"]["temperature_2m"][0]) print(f"Forecasted temp C: {forecasted_temp} degrees") return forecasted_temp @task def save_weather(temp: float): with open("weather.csv", "w+") as w: w.write(str(temp)) return "Successfully wrote temp" @flow def pipeline(lat: float = 38.9, lon: float = -77.0): temp = fetch_weather(lat, lon) result = save_weather(temp) return result if name == "__main__": pipeline()
m
✅ thought for 20.5 seconds
Thanks for sharing the code — that looks correct and should produce two task nodes in the run graph:
fetch_weather
then
save_weather
. Given your earlier DB reset issues and that you’re seeing “Pending” in the list but “Finished in state Completed()” inside the details, this sounds like a UI/state index mismatch caused by the local server/database getting into a weird state. Do this to get to a clean slate and verify: 1) Stop the local server - Make sure no
prefect server start
is running. If you’re using Docker, stop the container. 2) Find and delete the local SQLite DB - Show the exact DB URL (it’s masked by default):
Copy code
uv run prefect config view --show-sources --show-secrets | grep DATABASE_CONNECTION_URL
- Delete the SQLite file shown there (typically
~/.prefect/prefect.db
), e.g.:
Copy code
rm -f ~/.prefect/prefect.db
3) Reset and start the server cleanly
Copy code
uv run prefect server database reset -y
uv run prefect server start
Make sure you’re not using
--no-services
. 4) Ensure your Python session points at your local API
Copy code
export PREFECT_API_URL="<http://127.0.0.1:4200/api>"
5) Run your flow with logging captured
Copy code
from prefect import flow, task
import httpx

@task(log_prints=True)
def fetch_weather(lat: float, lon: float):
    base_url = "<https://api.open-meteo.com/v1/forecast>"
    temps = httpx.get(
        base_url,
        params=dict(latitude=lat, longitude=lon, hourly="temperature_2m"),
    )
    forecasted_temp = float(temps.json()["hourly"]["temperature_2m"][0])
    print(f"Forecasted temp C: {forecasted_temp} degrees")
    return forecasted_temp

@task(log_prints=True)
def save_weather(temp: float):
    print(f"Saving {temp} to weather.csv")
    with open("weather.csv", "w+") as w:
        w.write(str(temp))
    return "Successfully wrote temp"

@flow(log_prints=True)
def pipeline(lat: float = 38.9, lon: float = -77.0):
    temp = fetch_weather(lat, lon)
    result = save_weather(temp)
    return result

if __name__ == "__main__":
    pipeline()
6) In the UI - Hard refresh the browser and toggle “Live updates” on the run page. - You should see both tasks in the Graph tab and both in the Task Runs list as Completed. If the second task still doesn’t appear in the Graph: - Please share: -
uv run prefect version
- Confirmation you started with
uv run prefect server start
(no extra flags) - A screenshot of the run page (Graph tab + Task Runs tab) - Also, confirm you’re looking at the same API your Python is pointing to (Local UI at http://127.0.0.1:4200 and
PREFECT_API_URL
set to http://127.0.0.1:4200/api).
g
@Marvin i have "solved" the issue by putting a sleep on the save task: from prefect import flow, task import httpx import time @task def fetch_weather(lat: float, lon: float): base_url = "https://api.open-meteo.com/v1/forecast/" temps = httpx.get( base_url, params=dict(latitude=lat, longitude=lon, hourly="temperature_2m"), ) forecasted_temp = float(temps.json()["hourly"]["temperature_2m"][0]) print(f"Forecasted temp C: {forecasted_temp} degrees") return forecasted_temp @task def save_weather(temp: str): with open("weather.csv", "w+") as w: w.write(str(temp)) return "Successfully wrote temp" @flow def pipeline(lat: float = 38.9, lon: float = -77.0): temp = fetch_weather(lat, lon) result = save_weather(temp) time.sleep(1) return result if name == "__main__": pipeline() Heres the debug log: 163205.322 | DEBUG | prefect.profiles - Using profile 'local' 163206.128 | INFO | prefect - Starting temporary server on http://127.0.0.1:8730 See https://docs.prefect.io/v3/concepts/server#how-to-guides for more information on running a dedicated Prefect server. 163209.204 | DEBUG | prefect.task_runner.threadpool - Starting task runner 163209.208 | INFO | Flow run 'fiery-puffin' - Beginning flow run 'fiery-puffin' for flow 'pipeline' 163209.210 | DEBUG | prefect.utilities.urls - No URL found for the Prefect UI, and no default base path provided. 163209.210 | DEBUG | Flow run 'fiery-puffin' - Executing flow 'pipeline' for flow run 'fiery-puffin'... 163209.465 | DEBUG | prefect.client - Connecting to API at http://127.0.0.1:8730/api/ 163209.466 | DEBUG | prefect.client - Connecting to API at http://127.0.0.1:8730/api/ 163209.471 | DEBUG | Task run 'fetch_weather-c88' - Created task run 'fetch_weather-c88' for task 'fetch_weather' 163209.472 | DEBUG | Task run 'fetch_weather-c88' - Executing task 'fetch_weather' for task run 'fetch_weather-c88'... 163209.726 | DEBUG | prefect.events.clients - Reconnecting websocket connection. 163209.727 | DEBUG | prefect.events.clients - Opening websocket connection. 163209.729 | DEBUG | prefect.events.clients - Pinging to ensure websocket connected. 163209.730 | DEBUG | prefect.events.clients - Pong received. Websocket connected. 163209.730 | DEBUG | prefect.events.clients - Resending 0 unconfirmed events. 163209.730 | DEBUG | prefect.events.clients - Finished resending unconfirmed events. 163209.730 | DEBUG | prefect.client - Connecting to API at http://127.0.0.1:8730/api/ 163209.736 | DEBUG | prefect.events.clients - EventsClient(id=1727037201712): Emitting event id=0199f8ce-d87a-77bd-9594-b39dd867341c. 163209.736 | DEBUG | prefect.events.clients - Added event id=0199f8ce-d87a-77bd-9594-b39dd867341c to unconfirmed events list. There are now 1 unconfirmed events. 163209.736 | DEBUG | prefect.events.clients - EventsClient(id=1727037201712): Emit reconnection attempt 0. 163209.737 | DEBUG | prefect.events.clients - EventsClient(id=1727037201712): Sending event id=0199f8ce-d87a-77bd-9594-b39dd867341c. 163209.737 | DEBUG | prefect.events.clients - EventsClient(id=1727037201712): Checkpointing event id=0199f8ce-d87a-77bd-9594-b39dd867341c. 163209.738 | DEBUG | prefect.events.clients - EventsClient(id=1727037201712): Emitting event id=0199f8ce-d87f-75e4-8753-4d604c26cc26. 163209.738 | DEBUG | prefect.events.clients - Added event id=0199f8ce-d87f-75e4-8753-4d604c26cc26 to unconfirmed events list. There are now 2 unconfirmed events. 163209.738 | DEBUG | prefect.events.clients - EventsClient(id=1727037201712): Emit reconnection attempt 0. 163209.738 | DEBUG | prefect.events.clients - EventsClient(id=1727037201712): Sending event id=0199f8ce-d87f-75e4-8753-4d604c26cc26. 163209.739 | DEBUG | prefect.events.clients - EventsClient(id=1727037201712): Checkpointing event id=0199f8ce-d87f-75e4-8753-4d604c26cc26. Forecasted temp C: 13.3 degrees 163210.419 | INFO | Task run 'fetch_weather-c88' - Finished in state Completed() 163210.420 | DEBUG | prefect.events.clients - EventsClient(id=1727037201712): Emitting event id=0199f8ce-dc33-7034-ae5b-2ade75cf3652. 163210.421 | DEBUG | prefect.events.clients - Added event id=0199f8ce-dc33-7034-ae5b-2ade75cf3652 to unconfirmed events list. There are now 3 unconfirmed events. 163210.421 | DEBUG | prefect.events.clients - EventsClient(id=1727037201712): Emit reconnection attempt 0. 163210.421 | DEBUG | prefect.events.clients - EventsClient(id=1727037201712): Sending event id=0199f8ce-dc33-7034-ae5b-2ade75cf3652. 163210.422 | DEBUG | prefect.events.clients - EventsClient(id=1727037201712): Checkpointing event id=0199f8ce-dc33-7034-ae5b-2ade75cf3652. 163210.611 | DEBUG | prefect.client - Connecting to API at http://127.0.0.1:8730/api/ 163210.613 | DEBUG | Task run 'save_weather-6fe' - Created task run 'save_weather-6fe' for task 'save_weather' 163210.613 | DEBUG | prefect.events.clients - EventsClient(id=1727037201712): Emitting event id=0199f8ce-dcf4-71bf-92ee-47d6942fdd80. 163210.614 | DEBUG | Task run 'save_weather-6fe' - Executing task 'save_weather' for task run 'save_weather-6fe'... 163210.615 | DEBUG | prefect.events.clients - Added event id=0199f8ce-dcf4-71bf-92ee-47d6942fdd80 to unconfirmed events list. There are now 4 unconfirmed events. 163210.615 | DEBUG | prefect.events.clients - EventsClient(id=1727037201712): Emit reconnection attempt 0. 163210.616 | DEBUG | prefect.events.clients - EventsClient(id=1727037201712): Sending event id=0199f8ce-dcf4-71bf-92ee-47d6942fdd80. 163210.616 | DEBUG | prefect.events.clients - EventsClient(id=1727037201712): Checkpointing event id=0199f8ce-dcf4-71bf-92ee-47d6942fdd80. 163210.618 | INFO | Task run 'save_weather-6fe' - Finished in state Completed() 163210.621 | DEBUG | prefect.events.clients - EventsClient(id=1727037201712): Emitting event id=0199f8ce-dcf6-7182-80d9-662db1c8de5c. 163210.621 | DEBUG | prefect.events.clients - Added event id=0199f8ce-dcf6-7182-80d9-662db1c8de5c to unconfirmed events list. There are now 5 unconfirmed events. 163210.621 | DEBUG | prefect.events.clients - EventsClient(id=1727037201712): Emit reconnection attempt 0. 163210.622 | DEBUG | prefect.events.clients - EventsClient(id=1727037201712): Sending event id=0199f8ce-dcf6-7182-80d9-662db1c8de5c. 163210.622 | DEBUG | prefect.events.clients - EventsClient(id=1727037201712): Checkpointing event id=0199f8ce-dcf6-7182-80d9-662db1c8de5c. 163210.623 | DEBUG | prefect.events.clients - EventsClient(id=1727037201712): Emitting event id=0199f8ce-dcfa-7e6f-a42f-7c68e935dd1c. 163210.624 | DEBUG | prefect.events.clients - Added event id=0199f8ce-dcfa-7e6f-a42f-7c68e935dd1c to unconfirmed events list. There are now 6 unconfirmed events. 163210.625 | DEBUG | prefect.events.clients - EventsClient(id=1727037201712): Emit reconnection attempt 0. 163210.625 | DEBUG | prefect.events.clients - EventsClient(id=1727037201712): Sending event id=0199f8ce-dcfa-7e6f-a42f-7c68e935dd1c. 163210.625 | DEBUG | prefect.events.clients - EventsClient(id=1727037201712): Checkpointing event id=0199f8ce-dcfa-7e6f-a42f-7c68e935dd1c. 163211.628 | DEBUG | prefect.task_runner.threadpool - Stopping task runner 163211.629 | INFO | Flow run 'fiery-puffin' - Finished in state Completed() 163211.634 | INFO | prefect - Stopping temporary server on http://127.0.0.1:8730
m
Your message was too long, here's your message at the allowed limit: i have solved the issue by putting a sleep on the save task from prefect import flow task import httpx import time @task *def fetch_weather(lat float lon float) * *base_url = <https //api open-meteo com/v1/forecast/> * temps = httpx get( *base_url * *params=dict(latitude=lat longitude=lon hourly= temperature_2m ) * ) forecasted_temp = float(temps json()[ hourly ][ temperature_2m ][0]) print(f Forecasted temp C {forecasted_temp} degrees ) return forecasted_temp @task *def save_weather(temp str) * *with open( weather csv w+ ) as w * w write(str(temp)) *return Successfully wrote temp * @flow *def pipeline(lat float = 38 9 lon float = -77 0) * temp = fetch_weather(lat lon) result = save_weather(temp) time sleep(1) return result *if name == main * pipeline() *Heres the debug log * 16 32 05 322 | DEBUG | prefect profiles - Using profile 'local' 16 32 06 128 | INFO | prefect - Starting temporary server on <http //127 0 0 1 8730> See <https //docs prefect io/v3/concepts/server#how-to-guides> for more information on running a dedicated Prefect server 16 32 09 204 | DEBUG | prefect task_runner threadpool - Starting task runner 16 32 09 208 | INFO | Flow run 'fiery-puffin' - Beginning flow run 'fiery-puffin' for flow 'pipeline' 16 32 09 210 | DEBUG | prefect utilities urls - No URL found for the Prefect UI and no default base path provided 16 32 09 210 | DEBUG | Flow run 'fiery-puffin' - Executing flow 'pipeline' for flow run 'fiery-puffin' 16 32 09 465 | DEBUG | prefect client - Connecting to API at <http //127 0 0 1 8730/api/> 16 32 09 466 | DEBUG | prefect client - Connecting to API at <http //127 0 0 1 8730/api/> 16 32 09 471 | DEBUG | Task run 'fetch_weather-c88' - Created task run 'fetch_weather-c88' for task 'fetch_weather' 16 32 09 472 | DEBUG | Task run 'fetch_weather-c88' - Executing task 'fetch_weather' for task run 'fetch_weather-c88' 16 32 09 726 | DEBUG | prefect events clients - Reconnecting websocket connection 16 32 09 727 | DEBUG | prefect events clients - Opening websocket connection 16 32 09 729 | DEBUG | prefect events clients - Pinging to ensure websocket connected 16 32 09 730 | DEBUG | prefect events clients - Pong received Websocket connected 16 32 09 730 | DEBUG | prefect events clients - Resending 0 unconfirmed events 16 32 09 730 | DEBUG | prefect events clients - Finished resending unconfirmed events 16 32 09 730 | DEBUG | prefect client - Connecting to API at <http //127 0 0 1 8730/api/> 16 32 09 736 | DEBUG | prefect events clients - EventsClient(id=1727037201712) Emitting event id=0199f8ce-d87a-77bd-9594-b39dd867341c 16 32 09 736 | DEBUG | prefect events clients - Added event id=0199f8ce-d87a-77bd-9594-b39dd867341c to unconfirmed events list There are now 1 unconfirmed events 16 32 09 736 | DEBUG | prefect events clients - EventsClient(id=1727037201712) Emit reconnection attempt 0 16 32 09 737 | DEBUG | prefect events clients - EventsClient(id=1727037201712) Sending event id=0199f8ce-d87a-77bd-9594-b39dd867341c 16 32 09
g
@Marvin i have "solved" the issue by putting a sleep on the save task: from prefect import flow, task import httpx import time @task def fetch_weather(lat: float, lon: float): base_url = "https://api.open-meteo.com/v1/forecast/" temps = httpx.get( base_url, params=dict(latitude=lat, longitude=lon, hourly="temperature_2m"), ) forecasted_temp = float(temps.json()["hourly"]["temperature_2m"][0]) print(f"Forecasted temp C: {forecasted_temp} degrees") return forecasted_temp @task def save_weather(temp: str): with open("weather.csv", "w+") as w: w.write(str(temp)) return "Successfully wrote temp" @flow def pipeline(lat: float = 38.9, lon: float = -77.0): temp = fetch_weather(lat, lon) result = save_weather(temp) time.sleep(1) return result if name == "__main__": pipeline() Why is that?
m
✅ thought for 25.7 seconds
Great question — the sleep “fix” is a clue. What’s happening - Your flow and both tasks are actually finishing. The “Pending” you see briefly is a UI/state propagation lag. - Locally, the server persists events and artifacts (like the run graph) via background services that batch/flush on an interval. By default, the event persister flushes every ~5 seconds. - Adding
time.sleep(1)
keeps your process alive just a bit longer, giving those background services time to flush updates so the UI graph catches up. Without the delay, the run ends quickly and the UI can momentarily show stale state in the list/graph, even though the task’s detail page shows Completed. You don’t need the sleep — here are better options - Refresh and enable Live updates on the run page; give it a few seconds after the run completes. - Make sure you start the server with services enabled (default). Avoid
--no-services
. - Start it cleanly:
Copy code
uv run prefect server start
- For faster local feedback, reduce the flush interval:
Copy code
PREFECT_API_SERVICES_EVENT_PERSISTER_FLUSH_INTERVAL=1 uv run prefect server start
- Optional: enable task print logging so you see output in the UI immediately:
Copy code
@task(log_prints=True)
  def save_weather(temp: float):
      print(f"Saving {temp}…")
      ...
Why the mismatch between “Pending” list vs. “Completed” details? - The list/graph views depend on aggregated artifacts/events that are flushed on a schedule. - The task detail view queries the task run state directly, which is usually up-to-date first. - If you recently reset the DB while the server was running, that can also exacerbate temporary inconsistencies. If you still see oddities after a clean server start, please share: -
uv run prefect version
- How you’re starting the server (the exact command) - A quick screenshot of the run Graph tab vs. Task Runs tab Docs: - Flows and tasks: Flows and tasks - Settings reference (event persister settings): Settings reference
g
I have solved the database reset problem its working normally i have nuked it. In the UI i dont see any live updates enabler. Can i set this via CLI? PREFECT_API_SERVICES_EVENT_PERSISTER_FLUSH_INTERVAL=1 is this an env variable that i can set?
@Marvin I have solved the database reset problem its working normally i have nuked it. In the UI i dont see any live updates enabler. Can i set this via CLI? PREFECT_API_SERVICES_EVENT_PERSISTER_FLUSH_INTERVAL=1 is this an env variable that i can set?
m
✅ thought for 143.0 seconds
Short answers - There isn’t a “Live updates” toggle in the Prefect 3 UI. - Yes, you can control update frequency via settings. And yes,
PREFECT_API_SERVICES_EVENT_PERSISTER_FLUSH_INTERVAL=1
is a valid env var you can set. What to set - Flush interval (seconds):
PREFECT_API_SERVICES_EVENT_PERSISTER_FLUSH_INTERVAL
(default 5) - Batch size:
PREFECT_API_SERVICES_EVENT_PERSISTER_BATCH_SIZE
(default 20) - Enable/disable event persister:
PREFECT_API_SERVICES_EVENT_PERSISTER_ENABLED
(default True) - Event stream out to UI:
PREFECT_API_EVENTS_STREAM_OUT_ENABLED
(default True) Two ways to apply it Option A — Per run (environment variable)
Copy code
PREFECT_API_SERVICES_EVENT_PERSISTER_FLUSH_INTERVAL=1 uv run prefect server start
Option B — Persist to your Prefect profile (CLI)
Copy code
uv run prefect config set PREFECT_API_SERVICES_EVENT_PERSISTER_FLUSH_INTERVAL=1
uv run prefect config view --show-sources
# restart the server to pick up the new setting
uv run prefect server start
Notes - You can also use the SERVER-prefixed variants (e.g.,
PREFECT_SERVER_SERVICES_EVENT_PERSISTER_FLUSH_INTERVAL
); the API-prefixed ones work well for local
prefect server start
. - Lowering flush interval increases write frequency; great for local dev, but keep defaults in prod unless you have a reason. Settings reference: Settings reference
g
I have used uv run prefect config set PREFECT_API_SERVICES_EVENT_PERSISTER_FLUSH_INTERVAL=1 and it worked. I on Prefect 3. Setting this variable how is it stored? When i kill the cli it will reset too? Also, explain to me what it actually configures
@Marvin I have used uv run prefect config set PREFECT_API_SERVICES_EVENT_PERSISTER_FLUSH_INTERVAL=1 and it worked. I on Prefect 3. Setting this variable how is it stored? When i kill the cli it will reset too? Also, explain to me what it actually configures
m
✅ thought for 51.8 seconds
Great questions! How your change is stored -
prefect config set ...
writes the setting to your current Prefect profile and persists it on disk. - Location:
$PREFECT_HOME/profiles.toml
(by default
~/.prefect/profiles.toml
). - It does NOT reset when you close your terminal or kill the CLI; it remains until you change/unset it or switch profiles. - You can confirm the source:
Copy code
uv run prefect config view --show-sources
uv run prefect profile ls
- To unset or change later:
Copy code
uv run prefect config unset PREFECT_API_SERVICES_EVENT_PERSISTER_FLUSH_INTERVAL -y
# or set a new value
uv run prefect config set PREFECT_API_SERVICES_EVENT_PERSISTER_FLUSH_INTERVAL=5
What it actually configures -
PREFECT_API_SERVICES_EVENT_PERSISTER_FLUSH_INTERVAL
controls how often the server’s Event Persister flushes pending events to the database (in seconds). - The Event Persister batches events (task/flow state changes, logs, artifacts) and flushes when: - either the batch size is reached (
PREFECT_API_SERVICES_EVENT_PERSISTER_BATCH_SIZE
, default 20) - or the flush interval elapses (default 5s; you set it to 1s) - Lowering the interval makes the UI reflect updates faster, at the cost of more frequent DB writes. It’s a good tweak for local dev. - This is a server-side setting; restart the server to pick up changes:
Copy code
uv run prefect server start
Tips and notes - The setting is saved in the active profile only. If you switch profiles (e.g.,
uv run prefect profile use another-profile
) or run in a container without your local profiles, you won’t see the same behavior unless you set it there too. - You can also use the SERVER-prefixed variant; both are accepted: -
PREFECT_SERVER_SERVICES_EVENT_PERSISTER_FLUSH_INTERVAL
- Full settings reference: Settings reference - Profiles and settings overview: Settings and profiles
g
@Marvin uv run prefect config set PREFECT_API_SERVICES_EVENT_PERSISTER_FLUSH_INTERVAL=1 how can i remove this variable via CLI?
m
✅ thought for 9.7 seconds
Use unset to remove it from your active Prefect profile:
Copy code
uv run prefect config unset PREFECT_API_SERVICES_EVENT_PERSISTER_FLUSH_INTERVAL -y
Then restart your server to apply the change:
Copy code
uv run prefect server start
Verify it’s gone (and back to the default of 5 seconds):
Copy code
uv run prefect config view --show-sources
Notes: - This removes the setting from your current profile (stored in ~/.prefect/profiles.toml by default). - If you had also exported it as an environment variable in your shell, unset that too:
Copy code
unset PREFECT_API_SERVICES_EVENT_PERSISTER_FLUSH_INTERVAL
Docs: - Settings and profiles - Settings reference
g
Ok got it. About the env variable PREFECT_LOGGING_LOG_PRINTS = "True". Running locally i already get all the INFO logs. Do i need to set this only if im running on the cloud?
@Marvin Ok got it. About the env variable PREFECT_LOGGING_LOG_PRINTS = "True". Running locally i already get all the INFO logs. Do i need to set this only if im running on the cloud?
m
✅ thought for 106.4 seconds
You only need
PREFECT_LOGGING_LOG_PRINTS
if you want plain print() output to show up as Prefect logs. It applies both locally and in Cloud; it’s not Cloud-specific. Key points: - Default is False. By default, print() output is not routed through Prefect logging. - You can enable it globally with the setting: - Temporary (env var):
PREFECT_LOGGING_LOG_PRINTS=true
- Persisted (profile):
uv run prefect config set PREFECT_LOGGING_LOG_PRINTS=true
- Or enable per-flow/task with the decorator option, which overrides the global: -
@flow(log_prints=True)
and/or
@task(log_prints=True)
If you’re already seeing INFO logs, that’s from Prefect’s logger. Set
log_prints
only if you also want your print() statements to appear alongside those logs in the UI. Docs: - Add logging - Settings reference
g
Ok i see it now the difference between the two. Other question about the UI: when i select a run i can click o Parameters tab and i see my parameters: This in fetch_weather
Copy code
{
  "lat": [],
  "lon": []
}
But when i click on Task Runs and select a task and click on the parameters it shows for save_weather: { "temp": [ { "inputType": "task_run", "id": "0199f8ee-4053-7689-b7ac-67156b4c3528" } ] Giving my code: from prefect import flow, task import httpx import time @task def fetch_weather(lat: float, lon: float): base_url = "https://api.open-meteo.com/v1/forecast/" temps = httpx.get( base_url, params=dict(latitude=lat, longitude=lon, hourly="temperature_2m"), ) forecasted_temp = float(temps.json()["hourly"]["temperature_2m"][0]) print(f"Forecasted temp C: {forecasted_temp} degrees") return forecasted_temp @task def save_weather(temp: str): with open("weather.csv", "w+") as w: w.write(str(temp)) return "Successfully wrote temp" @flow def pipeline(lat: float = 38.9, lon: float = -77.0): temp = fetch_weather(lat, lon) result = save_weather(temp) return result if name == "__main__": pipeline() I was specting to get the values recieved by fetch_weather about len and lon anda astring for save_weather Why is that? This is what it shows on the flow parameters:
Copy code
{
  "lat": 38.9,
  "lon": -77
}
@Marvin Ok i see it now the difference between the two. Other question about the UI: when i select a run i can click o Parameters tab and i see my parameters: This in fetch_weather
Copy code
{
  "lat": [],
  "lon": []
}
But when i click on Task Runs and select a task and click on the parameters it shows for save_weather: { "temp": [ { "inputType": "task_run", "id": "0199f8ee-4053-7689-b7ac-67156b4c3528" } ] Giving my code: from prefect import flow, task import httpx import time @task def fetch_weather(lat: float, lon: float): base_url = "https://api.open-meteo.com/v1/forecast/" temps = httpx.get( base_url, params=dict(latitude=lat, longitude=lon, hourly="temperature_2m"), ) forecasted_temp = float(temps.json()["hourly"]["temperature_2m"][0]) print(f"Forecasted temp C: {forecasted_temp} degrees") return forecasted_temp @task def save_weather(temp: str): with open("weather.csv", "w+") as w: w.write(str(temp)) return "Successfully wrote temp" @flow def pipeline(lat: float = 38.9, lon: float = -77.0): temp = fetch_weather(lat, lon) result = save_weather(temp) return result if name == "__main__": pipeline() I was specting to get the values recieved by fetch_weather about len and lon anda astring for save_weather Why is that? This is what it shows on the flow parameters:
Copy code
{
  "lat": 38.9,
  "lon": -77
}
m
✅ thought for 204.7 seconds
You’re seeing two different things: - Flow run “Parameters” tab: shows the actual values you passed to the flow (e.g., lat=38.9, lon=-77). That’s the true parameter payload of the flow run. - Task run “Parameters” panel: shows provenance (task_inputs), not raw argument values. It answers “where did this input come from?” not “what was its literal value?” What you’re seeing is expected: - save_weather.temp shows:
Copy code
{
  "temp": [
    {
      "inputType": "task_run",
      "id": "<upstream task run id>"
    }
  ]
}
Because
temp
came from the output of the
fetch_weather
task run. That’s lineage: a task_run -> temp. - fetch_weather.lat and fetch_weather.lon show empty arrays:
Copy code
{
  "lat": [],
  "lon": []
}
Because those were plain primitives from your flow parameters with no trackable upstream dependency. Prefect does not dump literal values there; it only records provenance. In some cases you may see
inputType: "parameter"
or
inputType: "constant"
entries, but the UI does not display raw values for task arguments by default (partly for privacy/perf reasons). If you want to see the actual values at the task level: - Log them explicitly (best for local dev):
Copy code
from prefect import task, flow

  @task(log_prints=True)
  def fetch_weather(lat: float, lon: float):
      print(f"lat={lat}, lon={lon}")
      ...
- Or create an artifact with the inputs/outputs you care about:
Copy code
from prefect import artifacts

  @task
  def save_weather(temp: float):
      artifacts.create_markdown(f"Temp: {temp}")
      ...
References: - UI task run schema (task_inputs provenance): UI schemas - Flow/task concepts: Flows and tasks
g
@Marvin from prefect.blocks.system import Secret my_secret_block = Secret(value="chave") my_secret_block.save(name="secret-block") How to do this in the CLI?
m
✅ thought for 61.6 seconds
Short answer: there isn’t a Prefect CLI subcommand to set a Secret’s value inline. The CLI’s “create” command just prints a link to the UI. You have two practical options: Option A — Create via UI link - List block types and find the slug:
Copy code
uv run prefect block type ls
- Generate a creation link for Secret:
Copy code
uv run prefect block create secret
- Click the URL, fill Name = secret-block, Value = chave, Save. - Verify:
Copy code
uv run prefect block inspect secret/secret-block
Option B — One-liner from the shell (Python via uv) - Do the same thing you wrote in code but as a CLI one-liner:
Copy code
uv run python -c "from prefect.blocks.system import Secret; Secret(value='chave').save('secret-block')"
- Verify:
Copy code
uv run prefect block ls
uv run prefect block inspect secret/secret-block
Use in flows:
Copy code
from prefect.blocks.system import Secret
api_key = Secret.load("secret-block").get()
Notes: - The secret value will be encrypted at rest and masked in the UI/CLI. - Be mindful of shell history if you put secrets directly in command lines. Docs: - Blocks concept - Store secrets
g
About persisting results: from prefect import flow, task import pandas as pd @task(persist_result=True) def my_task(): df = pd.DataFrame(dict(a=[2, 3], b=[4, 5])) return df @flow def my_flow(): res = my_task() if name == "__main__": my_flow() What data im storing here? And i head the data is pickled. How can i unpickle it?
@Marvin About persisting results: from prefect import flow, task import pandas as pd @task(persist_result=True) def my_task(): df = pd.DataFrame(dict(a=[2, 3], b=[4, 5])) return df @flow def my_flow(): res = my_task() if name == "__main__": my_flow() What data im storing here? And i head the data is pickled. How can i unpickle it?
m
✅ thought for 236.9 seconds
Great question. In Prefect 3,
@task(persist_result=True)
tells Prefect to serialize the task’s return value and write it to result storage. What that means in practice: What is stored - The returned object (your pandas DataFrame) is serialized and written to a file in your configured result storage. - By default, that’s the local filesystem under ~/.prefect/storage/, using a UUID-like key. - The Prefect database stores only metadata (storage key, serializer, block id), not the actual bytes. How it’s serialized - Default serializer is cloudpickle (i.e., pickled via cloudpickle). - You can change the serializer or storage per-task/flow, or globally (e.g., to S3/GCS/Azure via blocks). - Note: pickled objects are Python/pandas-version dependent; prefer smaller/JSON-serializable results when possible. How to load it back (recommended) - Let Prefect do the unpickling for you. Fetch the task run’s state and ask Prefect to return the Python object:
Copy code
import asyncio
from prefect.client.orchestration import get_client
from prefect.results import get_state_result

async def load_latest_my_task_result():
    async with get_client() as client:
        # get the most recent run of 'my_task'
        trs = await client.read_task_runs(
            task_run_filter={"name": {"any_": ["my_task"]}},
            limit=1,
        )
        state = trs[0].state
        df = await get_state_result(state)  # fetches from storage and deserializes
        return df

df = asyncio.run(load_latest_my_task_result())
print(df)
Manual unpickle (not recommended, but possible) - If you really want to read the bytes yourself, you need the storage key and the serializer used. - Typically the file will be at ~/.prefect/storage/<storage_key>. Then:
Copy code
import os, cloudpickle

path = os.path.expanduser("~/.prefect/storage/<storage_key>")
with open(path, "rb") as f:
    df = cloudpickle.load(f)
Caveats: - The exact on-disk layout and metadata handling are internal details; using
get_state_result
keeps you insulated from changes and works for remote storage too. - Large pickled results can be slow/heavy; consider writing big artifacts (like parquet/CSV to object storage) and returning just a reference/URI. Useful settings and knobs: - Default result storage block: set globally or per task/flow (
result_storage=...
) - Default serializer:
PREFECT_RESULTS_DEFAULT_SERIALIZER
- Persist by default (all tasks):
PREFECT_TASKS_DEFAULT_PERSIST_RESULT=true
Docs: - Results overview: https://docs-3.prefect.io/v3/advanced/results - Serializers API: https://docs-3.prefect.io/v3/api-ref/python/prefect-serializers - Filesystems/blocks: https://docs-3.prefect.io/v3/api-ref/python/prefect-filesystems
g
Automations are acessible running locally?
@Marvin Automations are acessible running locally?
m
✅ thought for 161.9 seconds
Short answer: Automations are a Prefect Cloud feature — they’re not available when running the OSS server locally. What you can do locally instead - Send notifications from your flows using notification blocks (works in OSS): - Slack: SlackWebhook - Email: SendgridEmail or Apprise (SMTP) - Teams, PagerDuty, Opsgenie, etc. Example:
Copy code
from prefect.blocks.notifications import SlackWebhook

  @flow
  def my_flow():
      SlackWebhook.load("my-slack").notify("Flow finished!")
- Use deployment-level triggers in prefect.yaml (these are not Automations, but can react to events like schedule/bad states for a deployment). - Roll your own “automation” in Python by listening for events via API or by adding guards in flows/tasks. References: - Automations (Cloud): Creating automations - Notifications blocks: Notification blocks - Deployment triggers: Deployment triggers
g
@Marvin about Flow code storage. It has some option to use Github. I have a small project in mind, only using the local version of Prefect. Do i have something specific to connect to my github? How this can be helpful?
m
✅ thought for 267.4 seconds
You don’t need anything special to “connect” Prefect to GitHub, even when running locally. In Prefect 3, you point a deployment at a repo using flow.from_source(...). This replaces the old GitHub storage blocks from 2.x. Why it’s helpful (even for small, local projects) - Reproducibility: your deployments always pull the exact code/commit you specify. - Easy updates: push to Git, redeploy to pick up changes. - Works with private repos via credentials; public repos need nothing extra. Basic patterns Run code locally without Git (pure local dev): - Just call your flow or use serve:
Copy code
from my_flow_module import my_flow
my_flow()                 # ad-hoc run
# or
from prefect import flow
@flow
def my_flow(...):
    ...
my_flow.serve(name="local")   # local scheduler, no deployments needed
Create a deployment from GitHub (public repo):
Copy code
from prefect import flow
from prefect.storage import GitRepository

# Option A: direct URL string
flow.from_source(
    source="<https://github.com/owner/repo>",
    entrypoint="path/to/flow_file.py:my_flow",
).deploy(
    name="my-deployment",
    work_pool_name="process-pool"   # local process worker
)

# Option B: explicit GitRepository (choose branch/commit)
repo = GitRepository(
    url="<https://github.com/owner/repo>",
    branch="main",   # or commit_sha="abc123"
)
flow.from_source(repo, "path/to/flow_file.py:my_flow").deploy(
    name="my-deployment",
    work_pool_name="process-pool"
)
Private repositories - Use a token via GitCredentials or a Secret block. Example:
Copy code
from prefect.storage import GitRepository, GitCredentials
from prefect.blocks.system import Secret

# Save your token once
Secret(value="ghp_xxx").save("github-token")

creds = GitCredentials(access_token=Secret.load("github-token").get())
repo = GitRepository(url="<https://github.com/owner/private-repo>", credentials=creds)

flow.from_source(repo, "flows/my_flow.py:my_flow").deploy(
    name="secure-deployment",
    work_pool_name="process-pool"
)
Run it - Start a local work pool and worker to pick up deployments:
Copy code
uv run prefect work-pool create process-pool --type process
uv run prefect worker start -p process-pool
- Then kick off a run from the UI or:
Copy code
uv run prefect deployment run "my-flow/my-deployment"
Notes - No GitHub storage block needed; do not use deprecated 2.x patterns like
Deployment.build_from_flow()
or
prefect deployment build
. - For purely local iteration, you can skip deployments and Git entirely;
flow.serve()
is super convenient for local schedules. - Docs: - Store flow code: Store flow code - Deploy flows: Deployment versioning - GitHub integration notes: prefect-github
g
@Marvin why is it giving me errrors when i try to execute? from prefect import flow from prefect.deployments import run_deployment @flow def run_deployment_from_flow(): print("Running deployment from a flow") run_deployment( name="pipeline/my-first-managed-deployment", parameters={"lat": 1, "lon": 2} ) return if name == "__main__": run_deployment_from_flow() The error: 214640.996 | INFO | Flow run 'malachite-clam' - Finished in state Failed('Flow run encountered an exception: ObjectNotFound: None') Traceback (most recent call last): File "C:\Projetos\magnetron\.venv\Lib\site-packages\prefect\client\orchestration\_deployments\client.py", line 1024, in read_deployment_by_name response = await self.request( ^^^^^^^^^^^^^^^^^^^ ...<6 lines>... ) ^
m
✅ thought for 84.1 seconds
ObjectNotFound means Prefect can’t find a deployment with the name you gave in the API you’re connected to. Checklist to fix: 1) Confirm you’re pointed at the right API (local vs Cloud)
Copy code
uv run prefect config view --show-sources
Make sure
PREFECT_API_URL
is your local server if that’s where you created the deployment (e.g. http://127.0.0.1:4200/api). 2) Verify the deployment actually exists
Copy code
uv run prefect deployment ls
uv run prefect deployment inspect "pipeline/my-first-managed-deployment"
If inspect returns “not found”, the deployment doesn’t exist in this API. Since you “nuked” the DB earlier, any existing deployments were deleted — you’ll need to recreate them. 3) Create (or recreate) the deployment - In Python:
Copy code
from prefect import flow

@flow
def pipeline(lat: float = 38.9, lon: float = -77.0):
    ...

# run once to create the deployment
pipeline.deploy(
    name="my-first-managed-deployment",
    work_pool_name="process-pool",
)
- Or via CLI from your project root:
Copy code
uv run prefect deploy ./path/to/file.py:pipeline --name my-first-managed-deployment
(You’ll still need a work pool/worker; see next step.) 4) Ensure a worker is running for the target work pool
Copy code
uv run prefect work-pool create process-pool --type process
uv run prefect worker start -p process-pool
5) Call run_deployment with the exact name Your code is fine:
Copy code
from prefect import flow
from prefect.deployments import run_deployment

@flow
def run_deployment_from_flow():
    run_deployment(
        name="pipeline/my-first-managed-deployment",
        parameters={"lat": 1, "lon": 2},
    )
Tip: If you prefer, you can pass the deployment UUID instead of the “flow-name/deployment-name” string. If this still errors, please share: - Output of
uv run prefect deployment ls
- Output of
uv run prefect config view --show-sources
- How you created the deployment (UI, Python, or CLI) Docs: - Store flow code for deployments - Deployment/versioning guide
g
The project that i want to build i need to download data from two separate apis. This way i can do parallelism between these two apis. This way means that each one of them are a flow, so two separate flows, right? Do i need a flow to envelop these two flows? But also i need to download some other large files that has no dependencies. Should them be separate tasks or flow?
@Marvin The project that i want to build i need to download data from two separate apis. This way i can do parallelism between these two apis. This way means that each one of them are a flow, so two separate flows, right? Do i need a flow to envelop these two flows? But also i need to download some other large files that has no dependencies. Should them be separate tasks or flow?
m
✅ thought for 46.2 seconds
Great question. Short version: start with one parent flow and make each download an independent task; use a task runner to run them in parallel. Make separate flows only when you need separate schedules, ownership, or infrastructure. Recommended structure - Two APIs in parallel (IO-bound): use tasks + ThreadPoolTaskRunner - Large file downloads with no deps: also tasks; submit many in parallel (map over URLs) - Make them separate flows only if you want to: - schedule them independently - run them on different work pools/infrastructure - reuse them across projects - get their own run UI/metrics Example: single flow with parallel tasks (best for local dev)
Copy code
from prefect import flow, task
from prefect.task_runners import ThreadPoolTaskRunner
import httpx

@task(retries=2, retry_delay_seconds=5, timeout_seconds=60)
def fetch_api1():
    return httpx.get("<https://api.example.com/a>").json()

@task(retries=2, retry_delay_seconds=5, timeout_seconds=60)
def fetch_api2():
    return httpx.get("<https://api.example.com/b>").json()

@task(retries=2, retry_delay_seconds=5)
def download_file(url: str) -> str:
    # stream to disk; don’t return large bytes
    path = f"data/{url.split('/')[-1]}"
    with httpx.stream("GET", url) as r:
        r.raise_for_status()
        with open(path, "wb") as f:
            for chunk in r.iter_bytes():
                f.write(chunk)
    return path  # return the path, not file contents

@flow(task_runner=ThreadPoolTaskRunner(max_workers=8))
def etl(urls: list[str]):
    a1_f = fetch_api1.submit()
    a2_f = fetch_api2.submit()
    file_fs = [download_file.submit(u) for u in urls]

    a1 = a1_f.result()
    a2 = a2_f.result()
    paths = [f.result() for f in file_fs]
    return a1, a2, paths

if __name__ == "__main__":
    etl(["<https://example.com/large1.bin>", "<https://example.com/large2.bin>"])
When to use separate flows (and optionally run them in parallel) - If API A and API B should be deployed/scheduled separately or run on different workers:
Copy code
from prefect import flow
from prefect.deployments import run_deployment

@flow
def orchestrator():
    # kick off two independent deployed flows in parallel
    fr1 = run_deployment(name="api-a-flow/prod")
    fr2 = run_deployment(name="api-b-flow/prod")
    # optionally wait / poll if you need their results
This launches distinct flow runs that can execute concurrently across workers/machines. Additional tips - IO-bound work (HTTP, S3/GCS downloads): ThreadPoolTaskRunner is ideal. - CPU-bound work (parsing, compression): ProcessPoolTaskRunner is better. - Avoid returning large payloads; write to storage and return a reference/path. - Add retries/timeouts to network tasks. - Persisting results: only persist small/structured outputs; for large files, persist the file path or a URI. References: - Flows and tasks: Docs - Run flows in local processes: Guide - Results best practices: Results
g
@Marvin Ok i got it. It is only one flow, with three main tasks. Since these three main tasks are separate apis i can call them in parallel. is this right? And inside the subtasks where i download data i can ise concorrency async
m
✅ thought for 23.3 seconds
Yes—that’s a good plan. - One flow that orchestrates three independent “main” tasks - Run those tasks in parallel (IO‑bound HTTP) with a task runner - Inside each task, you can use async to fan out multiple downloads Pattern A: parallel tasks at the flow level (simple and effective for IO)
Copy code
from prefect import flow, task
from prefect.task_runners import ThreadPoolTaskRunner
import httpx

@task(retries=2, retry_delay_seconds=5, timeout_seconds=60)
def api_a():
    return httpx.get("<https://api.example.com/a>").json()

@task(retries=2, retry_delay_seconds=5, timeout_seconds=60)
def api_b():
    return httpx.get("<https://api.example.com/b>").json()

@task(retries=2, retry_delay_seconds=5)
def big_file(url: str) -> str:
    path = f"data/{url.split('/')[-1]}"
    with httpx.stream("GET", url) as r:
        r.raise_for_status()
        with open(path, "wb") as f:
            for chunk in r.iter_bytes():
                f.write(chunk)
    return path

@flow(task_runner=ThreadPoolTaskRunner(max_workers=8))
def pipeline(urls: list[str]):
    a_f = api_a.submit()
    b_f = api_b.submit()
    file_fs = [big_file.submit(u) for u in urls]

    a = a_f.result()
    b = b_f.result()
    paths = [f.result() for f in file_fs]
    return a, b, paths
Pattern B: async fan‑out inside a task (useful when each “main task” needs many concurrent calls)
Copy code
import asyncio, httpx
from prefect import task

@task
async def download_many(urls: list[str]) -> list[str]:
    limits = httpx.Limits(max_connections=10)
    timeout = httpx.Timeout(60)
    sem = asyncio.Semaphore(5)

    async with httpx.AsyncClient(limits=limits, timeout=timeout) as client:
        async def fetch(u):
            async with sem:
                path = f"data/{u.split('/')[-1]}"
                async with client.stream("GET", u) as r:
                    r.raise_for_status()
                    with open(path, "wb") as f:
                        async for chunk in r.aiter_bytes():
                            f.write(chunk)
                return path

        return await asyncio.gather(*[fetch(u) for u in urls])
Guidance: - It’s fine to combine both (parallel tasks + internal async), but be mindful of total concurrency so you don’t overwhelm the APIs or your machine. - Use retries/timeouts on network tasks. - For CPU‑bound work (parsing/compression), prefer ProcessPoolTaskRunner. - Return paths/handles for large files instead of the raw bytes. If you share a quick outline of your three APIs and the download counts, I can suggest concrete max_workers, timeouts, and concurrency caps.
g
@Marvin I want to build a new project where i download data from APIs. In it i want one flow that has two main tasks. I want these tasks to run in parallel because theyre from different APIs so its not possible to reach the requests limit of download. One of these tasks, that were are going to call TSE, i download data from 3 different endpoints. They can run in parallel too because theyre a single file each one to download so the limit wont be reached. In the other main task, that were calling Congresso, firstly i download a file thats necessary for the subflows of it. It has two subtasks that recieves data from this first task. The substaks can run in parallel because theyre from different apis so it wont reach a request limite. In substaks A we are going to download from thee different endpoints in parallel because it is a small number of requests to be done, so it wont reach a request limit. Subtask B, in other hand, needs to run in sync. Give me a simple example of how to build this flow following the best practices. Dont stack everything in one single file, separate them in folders and files to make sense
m
✅ thought for 80.6 seconds
Yes — one flow orchestrating three independent downloads is perfect. Run the “main” tasks in parallel, then fan out within each main task as needed. Below is a small, clean project layout that follows Prefect 3 best practices: flows orchestrate, tasks do work, and downloads stream to disk and return paths. Project layout - flows/ - pipeline.py (top-level flow) - tasks/ - tse.py (TSE endpoints) - congresso/ - base.py (manifest prerequisite) - subtask_a.py (parallel endpoints) - subtask_b.py (sequential endpoints) - utils/ - io.py (shared download helper) utils/io.py
Copy code
from pathlib import Path
import httpx

def ensure_dir(path: str | Path) -> Path:
    p = Path(path)
    p.mkdir(parents=True, exist_ok=True)
    return p

def download_stream(url: str, dest_path: str | Path, timeout: float = 60.0) -> str:
    dest_path = Path(dest_path)
    ensure_dir(dest_path.parent)
    with httpx.stream("GET", url, timeout=timeout) as r:
        r.raise_for_status()
        with open(dest_path, "wb") as f:
            for chunk in r.iter_bytes():
                f.write(chunk)
    return str(dest_path)
tasks/tse.py
Copy code
from pathlib import Path
from prefect import task, get_run_logger
from <http://utils.io|utils.io> import download_stream

# Replace these with your real endpoints
TSE_ENDPOINTS = {
    "tse_a": "<https://example.com/tse/a.csv>",
    "tse_b": "<https://example.com/tse/b.csv>",
    "tse_c": "<https://example.com/tse/c.csv>",
}

@task(retries=2, retry_delay_seconds=5, timeout_seconds=120, log_prints=True)
def download_tse_endpoint(name: str, url: str, out_dir: str = "data/tse") -> str:
    logger = get_run_logger()
    dest = Path(out_dir) / f"{name}.csv"
    <http://logger.info|logger.info>(f"Downloading TSE endpoint '{name}' -> {dest}")
    return download_stream(url, dest)
tasks/congresso/base.py
Copy code
from pathlib import Path
from prefect import task, get_run_logger
from <http://utils.io|utils.io> import download_stream

# Replace with your real manifest URL
CONGRESSO_MANIFEST_URL = "<https://example.com/congresso/manifest.json>"

@task(retries=2, retry_delay_seconds=5, timeout_seconds=120, log_prints=True)
def download_manifest(out_dir: str = "data/congresso") -> str:
    logger = get_run_logger()
    dest = Path(out_dir) / "manifest.json"
    <http://logger.info|logger.info>(f"Downloading Congresso manifest -> {dest}")
    return download_stream(CONGRESSO_MANIFEST_URL, dest)
tasks/congresso/subtask_a.py
Copy code
from pathlib import Path
from prefect import task, get_run_logger
from <http://utils.io|utils.io> import download_stream

# Replace these with your real endpoints
CONGRESSO_A_ENDPOINTS = {
    "a_1": "<https://example.com/congresso/a_1.json>",
    "a_2": "<https://example.com/congresso/a_2.json>",
    "a_3": "<https://example.com/congresso/a_3.json>",
}

@task(retries=2, retry_delay_seconds=5, timeout_seconds=120, log_prints=True)
def download_congresso_a_endpoint(name: str, url: str, manifest_path: str, out_dir: str = "data/congresso/a") -> str:
    # Use manifest_path if you need to read metadata for requests
    logger = get_run_logger()
    dest = Path(out_dir) / f"{name}.json"
    <http://logger.info|logger.info>(f"[A] Downloading {name} using manifest {manifest_path} -> {dest}")
    return download_stream(url, dest)
tasks/congresso/subtask_b.py ``` from pathlib import Path from prefect import task, get_run_logger from utils.io import download_stream # Replace with your real B endpoints that must run sequentially CONGRESSO_B_ENDPOINTS = [ "https://example.com/congresso/b_1.parquet", "https://example.com/congresso/b_2.parquet", ] @task(retries=2, retry_delay_seconds=5, timeout_seconds=600, log_prints=True) def download_congresso_b_sequential(manifest_path: str, out_dir: str = "data/congresso/b") -> list[str]: logger = get_run_logger() results = [] for i, url in enumerate(CONGRESSO_B_ENDPOINTS, start=1): dest = Path(out_dir) / f"b_{i}.parquet"
logger.info(f"[B] Sequential download {i}/{len(CONGRESSO_B_ENDPOINTS)} using manifest {manifest_path} -> {dest}") results.append(download_stream(url, dest)) return results
Copy code
flows/pipeline.py
from prefect import flow, get_run_logger from prefect.task_runners import ThreadPoolTaskRunner from tasks.tse import TSE_ENDPOINTS, download_tse_endpoint from tasks.congresso.base import download_manifest from tasks.congresso.subtask_a import CONGRESSO_A_ENDPOINTS, download_congresso_a_endpoint from tasks.congresso.subtask_b import download_congresso_b_sequential @flow(task_runner=ThreadPoolTaskRunner(max_workers=16), log_prints=True) def pipeline(): logger = get_run_logger() logger.info("Starting pipeline") # TSE: three endpoints in parallel tse_fs = [download_tse_endpoint.submit(name, url) for name, url in TSE_ENDPOINTS.items()] # Congresso: first download prerequisite (manifest) manifest_path = download_manifest() # Then A and B in parallel: # - A: three endpoints in parallel a_fs = [download_congresso_a_endpoint.submit(name, url, manifest_path) for name, url in CONGRESSO_A_ENDPOINTS.items()] # - B: must run sequentially, so a single task that loops internally b_f = download_congresso_b_sequential.submit(manifest_path) # Gather results tse_paths = [f.result() for f in tse_fs] a_paths = [f.result() for f in a_fs] b_paths = b_f.result() logger.info(f"TSE files: {tse_paths}") logger.info(f"Congresso A files: {a_paths}") logger.info(f"Congresso B files: {b_paths}") return {"tse": tse_paths, "congresso_a": a_paths, "congresso_b": b_paths} if name == "__main__": pipeline()
Copy code
Why this matches your requirements
- One flow orchestrates everything.
- TSE endpoints run in parallel.
- Congresso downloads the manifest first, then runs Subtask A (parallel) and Subtask B (sequential) at the same time.
- All downloads stream to disk; tasks return file paths, not large payloads.
- ThreadPoolTaskRunner is ideal for IO-bound HTTP work.

Tuning and tips
- Control parallelism by adjusting `max_workers` in ThreadPoolTaskRunner.
- Add rate limiting if you need it later (Prefect concurrency limits or a simple `asyncio.Semaphore` inside a task if you switch to async).
- Put secrets (API keys) in Secret blocks and read them in tasks — avoid Variables for sensitive data.
- For production, consider persisting only small results and store big files in object storage; return URIs instead of local paths.

Run it
- Start your local server (optional if you just want local logs/observability):
uv run prefect server start
Copy code
- Execute the flow:
uv run python flows/pipeline.py
Copy code
- Or serve/schedule locally:
uv run python -c "from flows.pipeline import pipeline; pipeline.serve(name='local-pipeline')" ```
g
So we have a stream download. This is useful for the TSE endpoints because theyre large CSV files. In Congresso i also have large CSVs but i also have tasks where i have to download a single json file or a great number of small json files. So im going to need other functions besides download_stream, right?
@Marvin So we have a stream download. This is useful for the TSE endpoints because theyre large CSV files. In Congresso i also have large CSVs but i also have tasks where i have to download a single json file or a great number of small json files. So im going to need other functions besides download_stream, right?
m
✅ thought for 50.4 seconds
Yes—keep
download_stream
for big CSVs, and add a couple of small JSON helpers so you don’t buffer large payloads unnecessarily or return huge objects from tasks. Here’s a minimal set of utilities I recommend: - download_stream(url, dest_path): for large CSVs or binaries - fetch_json(url): for a single small JSON you want in memory - save_json(url, dest_path): for a single JSON saved to disk - fetch_json_many_async(urls, out_dir, concurrency): for many small JSONs in parallel with an async client - write_ndjson(records, dest_path): if you prefer aggregating many small JSONs into one NDJSON file utils/io.py
Copy code
from __future__ import annotations

import asyncio
import json
from pathlib import Path
import hashlib
import httpx

def ensure_dir(path: str | Path) -> Path:
    p = Path(path)
    p.mkdir(parents=True, exist_ok=True)
    return p

def download_stream(url: str, dest_path: str | Path, timeout: float = 60.0) -> str:
    dest_path = Path(dest_path)
    ensure_dir(dest_path.parent)
    with httpx.stream("GET", url, timeout=timeout) as r:
        r.raise_for_status()
        with open(dest_path, "wb") as f:
            for chunk in r.iter_bytes():
                f.write(chunk)
    return str(dest_path)

def fetch_json(url: str, timeout: float = 30.0) -> dict:
    with httpx.Client(timeout=timeout) as client:
        r = client.get(url)
        r.raise_for_status()
        return r.json()

def save_json(url: str, dest_path: str | Path, timeout: float = 30.0) -> str:
    dest_path = Path(dest_path)
    ensure_dir(dest_path.parent)
    with httpx.Client(timeout=timeout) as client:
        r = client.get(url)
        r.raise_for_status()
        data = r.json()
    with open(dest_path, "w", encoding="utf-8") as f:
        json.dump(data, f, ensure_ascii=False)
    return str(dest_path)

def write_ndjson(records: list[dict], dest_path: str | Path) -> str:
    dest_path = Path(dest_path)
    ensure_dir(dest_path.parent)
    with open(dest_path, "w", encoding="utf-8") as f:
        for rec in records:
            f.write(json.dumps(rec, ensure_ascii=False) + "\n")
    return str(dest_path)

async def fetch_json_many_async(
    urls: list[str],
    out_dir: str | Path | None = None,
    concurrency: int = 10,
    timeout: float = 30.0,
) -> list[str] | list[dict]:
    """
    - If out_dir is provided, saves each JSON to a file and returns list of file paths.
    - Otherwise returns list of parsed dicts in memory.
    """
    sem = asyncio.Semaphore(concurrency)
    limits = httpx.Limits(max_connections=max(concurrency, 10))
    timeout_cfg = httpx.Timeout(timeout)
    ensure_dir(out_dir) if out_dir else None

    async def one(u: str):
        async with sem:
            async with httpx.AsyncClient(limits=limits, timeout=timeout_cfg) as client:
                r = await client.get(u)
                r.raise_for_status()
                data = r.json()
            if out_dir:
                # deterministic filename by URL hash
                name = hashlib.sha1(u.encode()).hexdigest() + ".json"
                path = Path(out_dir) / name
                with open(path, "w", encoding="utf-8") as f:
                    json.dump(data, f, ensure_ascii=False)
                return str(path)
            else:
                return data

    return await asyncio.gather(*[one(u) for u in urls])
Examples of using these in Prefect tasks - TSE large CSVs: keep streaming to disk (your current use of download_stream is perfect). - Congresso single small JSON: use save_json if you want a file, or fetch_json if you need the content in-memory downstream. - Congresso many small JSONs: use an async task that calls fetch_json_many_async with a concurrency cap. tasks/congresso/subtask_a.py (parallel small JSONs) ``` from prefect import task from utils.io import fetch_json_many_async @task(retries=2, retry_delay_seconds=5) async def download_many_congresso_a(urls: list[str], out_dir: str = "data/congresso/a") -> list[str]:
# writes each JSON to disk; returns list of file paths return await fetch_json_many_async(urls, out_dir=out_dir, concurrency=10, timeout=30.0)
Copy code
tasks/congresso/base.py (single JSON manifest)
from prefect import task from utils.io import save_json @task(retries=2, retry_delay_seconds=5) def download_manifest(url: str, out_dir: str = "data/congresso") -> str: return save_json(url, dest_path=f"{out_dir}/manifest.json", timeout=60.0) ``` Why split like this - You avoid holding big payloads in memory; tasks return paths. - Async client + concurrency for “many small JSONs” is efficient and controllable. - You can tune concurrency easily to respect rate limits. General guidance - Keep using ThreadPoolTaskRunner at the flow level for IO-bound tasks; async tasks will still work fine inside. - Add retries/timeouts on tasks (as shown) and keep per-request timeouts reasonable. - If you later need to aggregate many JSONs, prefer writing NDJSON files with write_ndjson or store them in object storage and return URIs. If you share some endpoint counts and typical sizes, I can suggest good defaults for concurrency and timeouts for each group.
g
@Marvin i want to put all of those configurations about timeout, and workers on easiy configurable place. Also i want to put some basic variables like the base urls. Should i use Prefet Variables or should i put in a toml config file?
m
✅ thought for 91.9 seconds
Great question. Short answer: use a small, versioned config file for your defaults, then allow overrides via env vars and (optionally) Prefect Variables for quick tweaks. Use Secret blocks for any sensitive values. Why this approach - Versioned config (toml/yaml/py) keeps your defaults with the code and survives DB resets. - Env vars let you override per machine/container without code changes. - Prefect Variables are handy for non-sensitive, runtime overrides via UI/CLI — but they live in the Prefect DB, so you’ll lose them if you nuke/reset the DB. - Secrets should never go in Variables; use Secret blocks. Minimal pattern Project layout: - config/ - settings.toml - loader.py - flows/ - pipeline.py config/settings.toml
Copy code
[tse]
base_url = "<https://api.tse.example>"
stream_timeout = 120

[congresso]
base_url = "<https://api.congresso.example>"
json_timeout = 30
json_concurrency = 10

[runner]
max_workers = 16
config/loader.py
Copy code
import os, json
from pathlib import Path
from dataclasses import dataclass
from typing import Any
from prefect.variables import Variable

try:
    import tomllib  # Python 3.11+
except ModuleNotFoundError:
    import tomli as tomllib  # pip install tomli for 3.10-

@dataclass
class Settings:
    tse_base_url: str
    tse_stream_timeout: float
    congresso_base_url: str
    congresso_json_timeout: float
    congresso_json_concurrency: int
    runner_max_workers: int

def _read_toml(path: str | Path) -> dict[str, Any]:
    p = Path(path)
    if not p.exists():
        return {}
    with open(p, "rb") as f:
        return tomllib.load(f)

def _get_var(name: str) -> str | None:
    # Prefect Variables override if set (non-sensitive only)
    try:
        v = Variable.get(name)
        return None if v in ("", None) else v
    except Exception:
        return None

def load_settings(config_path: str | Path = "config/settings.toml") -> Settings:
    cfg = _read_toml(config_path)

    def pick(var_name: str, env_name: str, default: Any) -> Any:
        # priority: Prefect Variable -> ENV -> TOML -> default
        v = _get_var(var_name)
        if v is not None:
            return v
        if (e := os.getenv(env_name)) is not None:
            return e
        return default

    return Settings(
        tse_base_url=str(pick("TSE_BASE_URL", "MYAPP_TSE_BASE_URL", cfg.get("tse", {}).get("base_url", ""))),
        tse_stream_timeout=float(pick("TSE_STREAM_TIMEOUT", "MYAPP_TSE_STREAM_TIMEOUT", cfg.get("tse", {}).get("stream_timeout", 120))),
        congresso_base_url=str(pick("CONGRESSO_BASE_URL", "MYAPP_CONGRESSO_BASE_URL", cfg.get("congresso", {}).get("base_url", ""))),
        congresso_json_timeout=float(pick("CONGRESSO_JSON_TIMEOUT", "MYAPP_CONGRESSO_JSON_TIMEOUT", cfg.get("congresso", {}).get("json_timeout", 30))),
        congresso_json_concurrency=int(pick("CONGRESSO_JSON_CONCURRENCY", "MYAPP_CONGRESSO_JSON_CONCURRENCY", cfg.get("congresso", {}).get("json_concurrency", 10))),
        runner_max_workers=int(pick("RUNNER_MAX_WORKERS", "MYAPP_RUNNER_MAX_WORKERS", cfg.get("runner", {}).get("max_workers", 16))),
    )
flows/pipeline.py (using the settings)
Copy code
from prefect import flow
from prefect.task_runners import ThreadPoolTaskRunner
from config.loader import load_settings

@flow
def pipeline():
    s = load_settings()
    # You can pass these into tasks or construct runners with them
    print("Using:", s)

if __name__ == "__main__":
    s = load_settings()
    pipeline.with_options(task_runner=ThreadPoolTaskRunner(max_workers=s.runner_max_workers))()
Using Prefect Variables for quick overrides (non-sensitive)
Copy code
uv run prefect variable set TSE_BASE_URL <https://api.tse.example>
uv run prefect variable set RUNNER_MAX_WORKERS 32
Unset to fall back to env/TOML/defaults:
Copy code
uv run prefect variable unset RUNNER_MAX_WORKERS -y
Where to put secrets - Use Secret blocks: ``` from prefect.blocks.system import Secret Secret(value="your-token").save("tse-token") # later
token = Secret.load("tse-token").get() ``` When to favor each option - Use TOML (or a Python settings module) for defaults you want in VCS and to survive DB resets. - Use env vars to change behavior per machine/container/CI runner. - Use Prefect Variables for non-sensitive toggles you want to edit quickly in the UI/CLI without code changes. - Use Secret blocks for credentials/tokens. Docs: - Variables: Variables - Settings and profiles: Settings and profiles - Blocks: Blocks If you share which knobs you want to control (timeouts, concurrency, base URLs, etc.), I can tailor the loader and naming to your exact needs.
g
@Marvin About this part: def _get_gar(name: str) -> str | None: # Se as VariĂĄveis do Prefect forem definidas, sobrescrevem try: v = Variable.get(name) return None if v in ("", None) else v except Exception: return None it gives me an error: "*Type "StrictVariableValue | Coroutine[Any, Any, StrictVariableValue]" is not assignable to return type "str | None"* Type "StrictVariableValue | Coroutine[Any, Any, StrictVariableValue]" is not assignable to type "str | None" Type "StrictFloat" is not assignable to type "str | None" "float" is not assignable to "str" "float" is not assignable to "None"" If i pass the return type as Any it resolves. Why and how can i resolve that. Alto explain to me: it says it can return a coroutine. It returns a coroutine only if i define this function as async?
m
✅ thought for 75.7 seconds
Great question — this is a typing nuance with Prefect’s “sync-compatible” APIs and the type of Variables. Why you see that error -
Variable.get(...)
is sync-compatible: its static type is “Sync[T]”, which expands to “T | Coroutine[Any, Any, T]”. - For Prefect Variables, T is
StrictVariableValue
(a union of JSON-like primitives: str | int | float | bool | None). - Your function is annotated to return
str | None
, but the type checker sees “StrictVariableValue | Coroutine[...]” coming back — not assignable to
str | None
. It also flags that the value might be a float/bool, not a string. Does it actually return a coroutine? - Only in async contexts. If you call
Variable.get
inside an async function, you should
await
it. - In sync code, it returns the value directly. The coroutine bit is in the type to support both contexts. Two clean ways to resolve Option A — Return the correct Prefect type and convert later
Copy code
from typing import Optional, cast
from prefect.variables import Variable
from prefect.types import StrictVariableValue

def get_var(name: str) -> Optional[StrictVariableValue]:
    # Cast removes the Sync[...] union for type-checkers
    v = cast(StrictVariableValue, Variable.get(name, default=None))
    return v
Then when you need a string/float/int, convert explicitly:
Copy code
def get_var_str(name: str) -> str | None:
    v = get_var(name)
    return None if v is None else str(v)

def get_var_int(name: str, default: int) -> int:
    v = get_var(name)
    if v is None:
        return default
    if isinstance(v, bool):
        return int(v)
    if isinstance(v, (int, float, str)):
        return int(v)
    raise TypeError(f"Unsupported type for int: {type(v)}")
Option B — Support async usage If you’re calling this inside async code, make the helper async and await:
Copy code
from typing import Optional
from prefect.variables import Variable
from prefect.types import StrictVariableValue

async def aget_var(name: str) -> Optional[StrictVariableValue]:
    return await Variable.get(name, default=None)

async def aget_var_str(name: str) -> str | None:
    v = await aget_var(name)
    return None if v is None else str(v)
Why casting helps - Prefect annotates many functions with a Sync[T] alias (T | Coroutine[..., T]) to be accurate in both sync/async contexts. - In purely sync helpers, using
cast(StrictVariableValue, Variable.get(...))
tells the type checker “treat this like the sync branch,” removing the coroutine from the union. Also note - Prefect Variables can hold numbers and booleans, not just strings. If you want all config as strings, coerce with `str(v)`; if you want typed config, convert thoughtfully (like the int/float helpers above). - If you truly want your helper to return only strings, reflect that in both the conversion and the annotation. Docs: - Variables: https://docs-3.prefect.io/v3/concepts/variables If you share which exact fields you want as str/int/float, I can sketch small typed getters you can drop into your loader.
g
My loader.py is this: import os, json, tomllib from pathlib import Path from dataclasses import dataclass from typing import Any, Optional, cast from prefect.variables import Variable from prefect.types import StrictVariableValue @dataclass class Settings: # TSE tse_base_url: str tse_stream_timeout: float # CONGRESSO congresso_base_url: str congresso_json_timeout: float congresso_json_concurrency: int runner_max_workers: int def _read_toml(path: str | Path) -> dict[str, Any]: p = Path(path) if not p.exists(): return {} with open(p, "rb") as f: return tomllib.load(f) def _get_var(name: str) -> Optional[StrictVariableValue]: # Se as VariĂĄveis do Prefect forem definidas, sobrescrevem try: v = cast(StrictVariableValue, Variable.get(name)) return None if v in ("", None) else v except Exception: return None def load_settings(config_path: str | Path = "config/settings.toml") -> Settings: cfg = _read_toml(config_path) def pick(var_name: str, env_name: str, default: Any) -> Any: # Prioridade: Prefect Variable -> ENV -> TOML -> default v = _get_var(var_name) if v is not None: return v if (e := os.getenv(env_name)) is not None: return e return default return Settings( # GERAL runner_max_workers=int(pick("RUNNER_MAX_WORKERS","MYAPP_RUNNER_MAX_WORKERS", cfg.get("runner", {}).get("max_workers", 16))), # TSE tse_base_url=str(pick("TSE_BASE_URL", "MYAPP_TSE_BASE_URL", cfg.get("tse", {}).get("base_url", ""))), tse_stream_timeout=float(pick("TSE_STREAM_TIMEOUT", "MYAPP_TSE_STREAM_TIMEOUT", cfg.get("tse", {}).get("stream_timeout", 120))), # CONGRESSO congresso_base_url=str(pick("CONGRESSO_BASE_URL","MYAPP_CONGRESSO_BASE_URL", cfg.get("congresso", {}).get("base_url", ""))), congresso_json_timeout=int(pick("CONGRESSO_JSON_TIMEOUT","MYAPP_CONGRESSO_JSON_TIMEOUT", cfg.get("congresso", {}).get("json_timeout", 30))), congresso_json_concurrency=int(pick("CONGRESSO_JSON_CONCURRENCY","MYAPP_CONGRESSO_JSON_CONCURRENCY", cfg.get("congresso", {}).get("json_concurrency", 10))), ) do i still need to convert explicty to other types?
@Marvin My loader.py is this: import os, json, tomllib from pathlib import Path from dataclasses import dataclass from typing import Any, Optional, cast from prefect.variables import Variable from prefect.types import StrictVariableValue @dataclass class Settings: # TSE tse_base_url: str tse_stream_timeout: float # CONGRESSO congresso_base_url: str congresso_json_timeout: float congresso_json_concurrency: int runner_max_workers: int def _read_toml(path: str | Path) -> dict[str, Any]: p = Path(path) if not p.exists(): return {} with open(p, "rb") as f: return tomllib.load(f) def _get_var(name: str) -> Optional[StrictVariableValue]: # Se as VariĂĄveis do Prefect forem definidas, sobrescrevem try: v = cast(StrictVariableValue, Variable.get(name)) return None if v in ("", None) else v except Exception: return None def load_settings(config_path: str | Path = "config/settings.toml") -> Settings: cfg = _read_toml(config_path) def pick(var_name: str, env_name: str, default: Any) -> Any: # Prioridade: Prefect Variable -> ENV -> TOML -> default v = _get_var(var_name) if v is not None: return v if (e := os.getenv(env_name)) is not None: return e return default return Settings( # GERAL runner_max_workers=int(pick("RUNNER_MAX_WORKERS","MYAPP_RUNNER_MAX_WORKERS", cfg.get("runner", {}).get("max_workers", 16))), # TSE tse_base_url=str(pick("TSE_BASE_URL", "MYAPP_TSE_BASE_URL", cfg.get("tse", {}).get("base_url", ""))), tse_stream_timeout=float(pick("TSE_STREAM_TIMEOUT", "MYAPP_TSE_STREAM_TIMEOUT", cfg.get("tse", {}).get("stream_timeout", 120))), # CONGRESSO congresso_base_url=str(pick("CONGRESSO_BASE_URL","MYAPP_CONGRESSO_BASE_URL", cfg.get("congresso", {}).get("base_url", ""))), congresso_json_timeout=int(pick("CONGRESSO_JSON_TIMEOUT","MYAPP_CONGRESSO_JSON_TIMEOUT", cfg.get("congresso", {}).get("json_timeout", 30))), congresso_json_concurrency=int(pick("CONGRESSO_JSON_CONCURRENCY","MYAPP_CONGRESSO_JSON_CONCURRENCY", cfg.get("congresso", {}).get("json_concurrency", 10))), ) do i still need to convert explicty to other types?
m
✅ thought for 27.1 seconds
Short answer: yes, keep doing explicit conversions when you build Settings. Here’s why and a couple tweaks you can make. Why explicit conversion is needed - Env vars are always strings. - Prefect Variables can be str/int/float/bool/None (StrictVariableValue). - TOML gives you typed values, but you’re mixing three sources. Explicitly coercing to the target type at the Settings boundary guarantees consistent types regardless of the source. Two fixes I recommend 1) Use Variable.get(..., default=None) to avoid try/except and keep strict typing
Copy code
from typing import Optional, cast
from prefect.variables import Variable
from prefect.types import StrictVariableValue

def _get_var(name: str) -> Optional[StrictVariableValue]:
    v = cast(StrictVariableValue, Variable.get(name, default=None))
    return None if v in ("", None) else v
- Note: Variable.get is “sync-compatible”, so the cast is just to silence the type checker about the potential coroutine type in async contexts. - If you call this from async code, create an async variant and await it. 2) Make sure your Settings casts match your dataclass annotations You currently have: - congresso_json_timeout annotated as float, but you cast with int(...). Either: - change to float(...) OR - change the dataclass field to int Example with consistent types
Copy code
return Settings(
    runner_max_workers=int(pick("RUNNER_MAX_WORKERS","MYAPP_RUNNER_MAX_WORKERS", cfg.get("runner", {}).get("max_workers", 16))),

    tse_base_url=str(pick("TSE_BASE_URL", "MYAPP_TSE_BASE_URL", cfg.get("tse", {}).get("base_url", ""))),
    tse_stream_timeout=float(pick("TSE_STREAM_TIMEOUT", "MYAPP_TSE_STREAM_TIMEOUT", cfg.get("tse", {}).get("stream_timeout", 120))),

    congresso_base_url=str(pick("CONGRESSO_BASE_URL","MYAPP_CONGRESSO_BASE_URL", cfg.get("congresso", {}).get("base_url", ""))),
    congresso_json_timeout=float(pick("CONGRESSO_JSON_TIMEOUT","MYAPP_CONGRESSO_JSON_TIMEOUT", cfg.get("congresso", {}).get("json_timeout", 30))),
    congresso_json_concurrency=int(pick("CONGRESSO_JSON_CONCURRENCY","MYAPP_CONGRESSO_JSON_CONCURRENCY", cfg.get("congresso", {}).get("json_concurrency", 10))),
)
Optional: typed pick helpers to centralize coercion and error messages
Copy code
def pick_str(var, env, default): 
    v = _get_var(var); 
    if v is not None: return str(v)
    e = os.getenv(env)
    return e if e is not None else str(default)

def pick_int(var, env, default):
    v = _get_var(var)
    if v is not None:
        if isinstance(v, bool):  # avoid True->1 surprises
            raise TypeError(f"{var} should be int, got bool")
        return int(v)
    e = os.getenv(env)
    if e is not None: return int(e)
    return int(default)

def pick_float(var, env, default):
    v = _get_var(var)
    if v is not None:
        if isinstance(v, bool):
            raise TypeError(f"{var} should be float, got bool")
        return float(v)
    e = os.getenv(env)
    if e is not None: return float(e)
    return float(default)
About “it says it can return a coroutine” - Prefect marks some APIs as sync-compatible: their type is T | Coroutine[..., T]. - In sync code, they return T directly; in async code, you must await them. - Your helper is sync, so using cast(...) as shown is the right way to tell the type checker you’re using the sync branch. Bottom line - Keep explicit conversions when building Settings; it’s the safest way to normalize mixed sources. - Fix the float/int mismatch for congresso_json_timeout. - Use Variable.get(..., default=None) to avoid try/except and keep the code tidy. Docs for Variables: Variables
g
I only want a config file as a TOML
@Marvin ok i want only the TOML file as a config file
m
✅ thought for 33.3 seconds
Got it — you want a single TOML file to be the source of truth. Here’s a simple, typed setup that uses only TOML (no env vars, no Prefect Variables), with sensible defaults and clear errors if required values are missing. Project layout - config/ - settings.toml - loader.py config/settings.toml
Copy code
[tse]
base_url = "<https://api.tse.example>"
stream_timeout = 120

[congresso]
base_url = "<https://api.congresso.example>"
json_timeout = 30
json_concurrency = 10

[runner]
max_workers = 16
config/loader.py
Copy code
from __future__ import annotations

from dataclasses import dataclass
from pathlib import Path
from typing import Any

try:
    import tomllib  # Python 3.11+
except ModuleNotFoundError:
    import tomli as tomllib  # pip install tomli for 3.10

@dataclass
class Settings:
    # TSE
    tse_base_url: str
    tse_stream_timeout: float = 120.0

    # CONGRESSO
    congresso_base_url: str
    congresso_json_timeout: float = 30.0
    congresso_json_concurrency: int = 10

    # Runner
    runner_max_workers: int = 16

def _read_toml(path: str | Path) -> dict[str, Any]:
    p = Path(path)
    if not p.exists():
        raise FileNotFoundError(f"Config file not found: {p.resolve()}")
    with open(p, "rb") as f:
        return tomllib.load(f)

def load_settings(config_path: str | Path = "config/settings.toml") -> Settings:
    cfg = _read_toml(config_path)

    # Required keys (fail fast with clear errors)
    try:
        tse_cfg = cfg["tse"]
        congresso_cfg = cfg["congresso"]
    except KeyError as e:
        raise ValueError(f"Missing section in TOML: {e}. Expected [tse], [congresso].") from e

    try:
        tse_base_url = str(tse_cfg["base_url"])
        congresso_base_url = str(congresso_cfg["base_url"])
    except KeyError as e:
        raise ValueError(f"Missing required key: {e}. Ensure base_url is set.") from e

    # Optional keys with defaults from the dataclass
    tse_stream_timeout = float(tse_cfg.get("stream_timeout", Settings.tse_stream_timeout))
    congresso_json_timeout = float(congresso_cfg.get("json_timeout", Settings.congresso_json_timeout))
    congresso_json_concurrency = int(congresso_cfg.get("json_concurrency", Settings.congresso_json_concurrency))
    runner_max_workers = int(cfg.get("runner", {}).get("max_workers", Settings.runner_max_workers))

    return Settings(
        tse_base_url=tse_base_url,
        tse_stream_timeout=tse_stream_timeout,
        congresso_base_url=congresso_base_url,
        congresso_json_timeout=congresso_json_timeout,
        congresso_json_concurrency=congresso_json_concurrency,
        runner_max_workers=runner_max_workers,
    )
Using it in your flow
Copy code
from prefect import flow
from prefect.task_runners import ThreadPoolTaskRunner
from config.loader import load_settings

@flow
def pipeline():
    s = load_settings()
    # pass settings to tasks as needed
    return {"runner": s.runner_max_workers}

if __name__ == "__main__":
    s = load_settings()
    pipeline.with_options(task_runner=ThreadPoolTaskRunner(max_workers=s.runner_max_workers))()
Notes and best practices - Keep secrets out of TOML; use Prefect Secret blocks for tokens/keys and read them in code when needed. - If you want environment-specific configs later, you can add files like config/settings.dev.toml and pass the path to load_settings(...) without changing any other code. - TOML preserves types, but still coerce to float/int at load time to be explicit and future-proof. If you want stricter validation (e.g., URL format checks), I can show a pydantic-based loader too.
g
from prefect import flow, get_run_logger from prefect.task_runners import ThreadPoolTaskRunner from config.loader import load_settings @flow(task_runner=ThreadPoolTaskRunner(max_workers=16), log_prints=True) def pipeline(): logger = get_run_logger() logger.info("Iniciando pipeline") if name == "__main__": s = load_settings() pipeline.with_options( task_runner=ThreadPoolTaskRunner(max_workers=s.max_workers) )() This gives me errors: from prefect import flow, get_run_logger from prefect.task_runners import ThreadPoolTaskRunner from config.loader import load_settings @flow(task_runner=ThreadPoolTaskRunner(max_workers=16), log_prints=True) def pipeline(): logger = get_run_logger() logger.info("Iniciando pipeline") if name == "__main__": s = load_settings() pipeline.with_options( task_runner=ThreadPoolTaskRunner(max_workers=s.max_workers) )() and ge
@Marvin from prefect import flow, get_run_logger from prefect.task_runners import ThreadPoolTaskRunner from config.loader import load_settings @flow(task_runner=ThreadPoolTaskRunner(max_workers=16), log_prints=True) def pipeline(): logger = get_run_logger() logger.info("Iniciando pipeline") if name == "__main__": s = load_settings() pipeline.with_options( task_runner=ThreadPoolTaskRunner(max_workers=s.max_workers) )() This gives me errors: from prefect import flow, get_run_logger from prefect.task_runners import ThreadPoolTaskRunner from config.loader import load_settings @flow(task_runner=ThreadPoolTaskRunner(max_workers=16), log_prints=True) def pipeline(): logger = get_run_logger() logger.info("Iniciando pipeline") if name == "__main__": s = load_settings() pipeline.with_options( task_runner=ThreadPoolTaskRunner(max_workers=s.max_workers) )() and annot access attribute "with_options" for class "FunctionType" Attribute "with_options" is unknown
m
✅ thought for 41.4 seconds
You’ve got two separate issues: 1) The logger line has Slack formatting in your Python - Replace this:
Copy code
<http://logger.info|logger.info>("Iniciando pipeline")
- The
<http://logger.info|logger.info>
is Slack’s link syntax and will cause a syntax/type error in Python. 2) with_options “unknown” is a typing/usage mismatch - In Prefect 3,
@flow
returns a Flow object and
with_options(...)
is valid. Some linters/type-checkers still think it’s a plain function. At runtime it works if
pipeline
is actually decorated with
@flow
. - Also, in your Settings dataclass the field is likely
runner_max_workers
(not
max_workers
). Using
s.max_workers
would raise an attribute error. Two clean patterns Pattern A — Compute runner from TOML, then with_options at call time
Copy code
from prefect import flow, get_run_logger
from prefect.task_runners import ThreadPoolTaskRunner
from config.loader import load_settings

@flow(log_prints=True)  # no runner here; we’ll set it at call time
def pipeline():
    logger = get_run_logger()
    <http://logger.info|logger.info>("Iniciando pipeline")

if __name__ == "__main__":
    s = load_settings()
    runner = ThreadPoolTaskRunner(max_workers=s.runner_max_workers)
    pipeline.with_options(task_runner=runner)()
If your type checker still complains that
with_options
doesn’t exist, it’s safe to add a cast or ignore:
Copy code
from typing import cast
from prefect.flows import Flow

cast(Flow, pipeline).with_options(task_runner=runner)()
# or: pipeline.with_options(task_runner=runner)()  # type: ignore[attr-defined]
Pattern B — Use TOML at import time and set the runner in the decorator
Copy code
from prefect import flow, get_run_logger
from prefect.task_runners import ThreadPoolTaskRunner
from config.loader import load_settings

s = load_settings()
runner = ThreadPoolTaskRunner(max_workers=s.runner_max_workers)

@flow(task_runner=runner, log_prints=True)
def pipeline():
    logger = get_run_logger()
    <http://logger.info|logger.info>("Iniciando pipeline")

if __name__ == "__main__":
    pipeline()
This is simplest if you’re fine loading config at import time. Quick checklist - Make sure
runner_max_workers
in your TOML/Settings matches what you use in code. - Keep the logger line as plain Python. -
with_options
is the right way to override options per call in Prefect 3. Docs: Flows and tasks
g
@Marvin from prefect import flow, get_run_logger from prefect.task_runners import ThreadPoolTaskRunner from config.loader import load_settings s = load_settings() runner = ThreadPoolTaskRunner(max_workers=s.max_workers) @flow(task_runner=runner, log_prints=True) def pipeline(): logger = get_run_logger() logger.info("Iniciando pipeline") if name == "__main__": pipeline() still got error on @flow(task_runner=runner, log_prints=True) No overloads for "__call__" match the provided argumentsPylancereportCallIssue
m
✅ thought for 43.4 seconds
Two things going on: 1) Remove Slack formatting from your Python You still have Slack’s link markup in the code. Change this line to plain Python:
Copy code
<http://logger.info|logger.info>("Iniciando pipeline")
2) Pylance “No overloads for __call__” is a type-checker false positive In Prefect 3, passing a TaskRunner instance in the decorator is valid, but some editors’ type stubs don’t recognize it. The code will run. You can either suppress the warning or use a pattern that Pylance is happier with. Clean working examples A) Keep runner in the decorator (runtime works; may need a type-ignore)
Copy code
from prefect import flow, get_run_logger
from prefect.task_runners import ThreadPoolTaskRunner
from config.loader import load_settings

s = load_settings()
runner = ThreadPoolTaskRunner(max_workers=s.runner_max_workers)  # ensure field name matches Settings

@flow(task_runner=runner, log_prints=True)  # mypy/pylance may complain, but this runs
def pipeline():
    logger = get_run_logger()
    <http://logger.info|logger.info>("Iniciando pipeline")

if __name__ == "__main__":
    pipeline()
If your editor still complains, add:
Copy code
@flow(task_runner=runner, log_prints=True)  # type: ignore[call-arg]
B) Move the runner to with_options at call time (usually quiets the linter)
Copy code
from typing import cast
from prefect import flow, get_run_logger
from prefect.flows import Flow
from prefect.task_runners import ThreadPoolTaskRunner
from config.loader import load_settings

@flow(log_prints=True)
def pipeline():
    logger = get_run_logger()
    <http://logger.info|logger.info>("Iniciando pipeline")

if __name__ == "__main__":
    s = load_settings()
    runner = ThreadPoolTaskRunner(max_workers=s.runner_max_workers)
    cast(Flow, pipeline).with_options(task_runner=runner)()
Common gotchas to check - Make sure your Settings field is runner_max_workers (not max_workers) if that’s how you defined it. - Ensure you’re on Prefect 3.x:
Copy code
uv run prefect version
- Don’t include Slack formatting in code blocks you paste into your editor. Docs: - Flows and tasks: Flows and tasks
g
@Marvin ok lets forget about the config file and go back to the file and folder architecture and how i can structure my project. Im goint to tell you the current tasks and dependencies that my project has and you tell me the bests practices to implement them (which folders, how to declare functions) for the desired outcome. I have a flow. In the flow i want to download the TSE endpoints, they are 30 endpoints that can be downloaded in parallel because theyre not going to reach the request limit. Most of them are large zip but also i got a number of small zip ones. The function you gave it to me is fine. The other task is Congresso, where i start downloading Legislatura, a single json file that returns data directly to other two tasks: Deputados and Frentes that are a single json file. How should i structure following best practices the flow and these tasks?
m
✅ thought for 64.4 seconds
Great plan. Here’s a clean, “Prefect 3 best practices” project layout for your case: - One orchestrating flow - Three main areas of work: - TSE: ~30 ZIP endpoints in parallel (stream to disk) - Congresso: first get Legislatura (small JSON), then run Deputados and Frentes in parallel; both depend on Legislatura Recommended layout - flows/ - pipeline.py - tasks/ - tse.py - congresso/ - legislatura.py - deputados.py - frentes.py - utils/ - io.py utils/io.py - Shared helpers for streaming large files and fetching/saving JSON
Copy code
from __future__ import annotations
from pathlib import Path
import json
import httpx

def ensure_dir(path: str | Path) -> Path:
    p = Path(path)
    p.mkdir(parents=True, exist_ok=True)
    return p

def filename_from_url(url: str) -> str:
    # naive: last path segment
    return url.rstrip("/").split("/")[-1] or "download.bin"

def download_stream(url: str, dest_path: str | Path, timeout: float = 120.0) -> str:
    dest_path = Path(dest_path)
    ensure_dir(dest_path.parent)
    with httpx.stream("GET", url, timeout=timeout) as r:
        r.raise_for_status()
        with open(dest_path, "wb") as f:
            for chunk in r.iter_bytes():
                f.write(chunk)
    return str(dest_path)

def fetch_json(url: str, timeout: float = 30.0) -> dict:
    with httpx.Client(timeout=timeout) as client:
        r = client.get(url)
        r.raise_for_status()
        return r.json()

def save_json(url: str, dest_path: str | Path, timeout: float = 60.0) -> str:
    data = fetch_json(url, timeout=timeout)
    dest_path = Path(dest_path)
    ensure_dir(dest_path.parent)
    with open(dest_path, "w", encoding="utf-8") as f:
        json.dump(data, f, ensure_ascii=False)
    return str(dest_path)
tasks/tse.py - One task per endpoint; keep the list of endpoints here
Copy code
from pathlib import Path
from prefect import task, get_run_logger
from <http://utils.io|utils.io> import download_stream, filename_from_url

# Fill this with your 30 URLs
TSE_ENDPOINTS: list[str] = [
    # "https://.../file1.zip",
    # ...
]

@task(retries=2, retry_delay_seconds=5, timeout_seconds=600, log_prints=True)
def download_tse_zip(url: str, out_dir: str = "data/tse") -> str:
    logger = get_run_logger()
    name = filename_from_url(url)
    dest = Path(out_dir) / name
    <http://logger.info|logger.info>(f"TSE: downloading {url} -> {dest}")
    return download_stream(url, dest)
tasks/congresso/legislatura.py - Small JSON that feeds Deputados/Frentes
Copy code
from prefect import task, get_run_logger
from <http://utils.io|utils.io> import fetch_json

# Replace with real endpoint
LEGISLATURA_URL = "<https://api.congresso.example/legislatura>"

@task(retries=2, retry_delay_seconds=5, timeout_seconds=60, log_prints=True)
def fetch_legislatura() -> dict:
    logger = get_run_logger()
    <http://logger.info|logger.info>("Congresso: fetching Legislatura")
    return fetch_json(LEGISLATURA_URL, timeout=60.0)
tasks/congresso/deputados.py - Depends on Legislatura; save to disk or return JSON. Below: save to disk.
Copy code
from pathlib import Path
from prefect import task, get_run_logger
from <http://utils.io|utils.io> import save_json

# Template; build the actual URL from legislatura fields
def deputados_url(legislatura: dict) -> str:
    # e.g., f"<https://api.congresso.example/deputados?legislatura={legislatura['id']}>"
    return "<https://api.congresso.example/deputados>"

@task(retries=2, retry_delay_seconds=5, timeout_seconds=120, log_prints=True)
def fetch_deputados(legislatura: dict, out_dir: str = "data/congresso") -> str:
    logger = get_run_logger()
    url = deputados_url(legislatura)
    dest = Path(out_dir) / "deputados.json"
    <http://logger.info|logger.info>(f"Congresso: fetching Deputados from {url} -> {dest}")
    return save_json(url, dest, timeout=60.0)
tasks/congresso/frentes.py - Also depends on Legislatura; run in parallel with Deputados ``` from pathlib import Path from prefect import task, get_run_logger from utils.io import save_json # Template; build the actual URL from legislatura fields
def frentes_url(legislatura: dict) -> str: # e.g., f"https://api.congresso.example/frentes?legislatura={legislatura['id']}" return "https://api.congresso.example/frentes" @task(retries=2, retry_delay_seconds=5, timeout_seconds=120, log_prints=True) def fetch_frentes(legislatura: dict, out_dir: str = "data/congresso") -> str: logger = get_run_logger() url = frentes_url(legislatura) dest = Path(out_dir) / "frentes.json" logger.info(f"Congresso: fetching Frentes from {url} -> {dest}") return save_json(url, dest, timeout=60.0)
Copy code
flows/pipeline.py
- Orchestrate everything; TSE downloads run in parallel; Legislatura first, then Deputados and Frentes in parallel
from prefect import flow, get_run_logger from prefect.task_runners import ThreadPoolTaskRunner from tasks.tse import TSE_ENDPOINTS, download_tse_zip from tasks.congresso.legislatura import fetch_legislatura from tasks.congresso.deputados import fetch_deputados from tasks.congresso.frentes import fetch_frentes @flow(task_runner=ThreadPoolTaskRunner(max_workers=16), log_prints=True) def pipeline(): logger = get_run_logger() logger.info("Starting pipeline") # TSE: fan out ~30 endpoints in parallel tse_fs = [download_tse_zip.submit(url) for url in TSE_ENDPOINTS] # Congresso: fetch Legislatura first (dependency) legislatura = fetch_legislatura() # Then Deputados and Frentes in parallel (both depend on Legislatura) deputados_f = fetch_deputados.submit(legislatura) frentes_f = fetch_frentes.submit(legislatura) # Gather results tse_paths = [f.result() for f in tse_fs] deputados_path = deputados_f.result() frentes_path = frentes_f.result() logger.info(f"TSE files: {len(tse_paths)}") logger.info(f"Deputados -> {deputados_path}") logger.info(f"Frentes -> {frentes_path}") return { "tse": tse_paths, "congresso_deputados": deputados_path, "congresso_frentes": frentes_path, } if name == "__main__": pipeline() ``` Notes and best practices - Keep big files streamed to disk; return file paths from tasks. - Keep small JSON in-memory only when you need it downstream; otherwise save to disk (as above). - Add retries/timeouts to all network tasks (already shown). - Control concurrency with ThreadPoolTaskRunner(max_workers=...). Even with “no request limit”, a sane cap (e.g., 10–20) prevents local resource exhaustion. - Keep URLs and per-API assembly logic near each task module (as shown with deputados_url/frentes_url) so dependency wiring stays obvious. - If any endpoints need auth, load credentials from Secret blocks inside the task. If you drop in your real URLs/parameterization (especially how Legislatura shapes Deputados/Frentes URLs), I can fill those helper functions precisely.
g
Ok now im executing successfully. But im getting errors on some endpoints. I would like to reexecute only those tasks to troubleshoot. What is the patters? Do i need to create a deploy to run only some tasks?
@Marvin Ok now im executing successfully. But im getting errors on some endpoints. I would like to reexecute only those tasks to troubleshoot. What is the patters? Do i need to create a deploy to run only some tasks?
m
✅ thought for 276.6 seconds
Great to hear you’re executing successfully! Short answer: you don’t need a deployment to re-run only some work. In Prefect 3.x there isn’t a built-in “re-run just this failed task” button after a flow run completes. The common pattern is to start a new flow run that targets only the endpoints you care about, and design your flow to skip or cache already-successful work. Recommended patterns: 1) Parameterize which endpoints to run - Add a parameter (e.g.,
only
) and use native Python filtering so a new run can execute just a subset. - Combine with task retries for transient failures.
Copy code
from datetime import timedelta
from prefect import flow, task
from prefect.cache_policies import CachePolicy

@task(
    retries=2,
    retry_delay_seconds=10,
    cache_policy=CachePolicy(),
    cache_expiration=timedelta(days=1),
)
def call_endpoint(name: str):
    # call your endpoint here
    ...

@flow
def sync_all(endpoints: list[str], only: list[str] | None = None):
    targets = [e for e in endpoints if not only or e in only]
    # Either map or loop — both are fine
    call_endpoint.map(targets)
- First run:
Copy code
sync_all(endpoints=ALL_ENDPOINTS)
- Re-run only failing ones:
Copy code
sync_all(endpoints=ALL_ENDPOINTS, only=["/users", "/orders"])
2) Use result caching to avoid re-running successful work - With the
CachePolicy
above, when you re-run the flow with the same inputs, Prefect loads cached results for tasks that already succeeded and re-executes only the ones that need work. - If you want to force re-execution of a task even if there’s a cache, set
refresh_cache=True
on the task (in the decorator) temporarily, or adjust your cache key policy. - Docs: Result caching 3) Subflows per unit of work - Wrap each endpoint in its own subflow, then call just the ones you want in a subsequent run.
Copy code
from prefect import flow

@flow
def sync_one(endpoint: str):
    call_endpoint(endpoint)

@flow
def sync_all(endpoints: list[str], only: list[str] | None = None):
    for e in endpoints:
        if not only or e in only:
            sync_one(e)
4) Retries for transient errors - Configure retries on tasks so transient issues are automatically retried during the same run. - Docs: Task retries Do I need a deployment? - No. You can just call the flow function with the
only
parameter for ad-hoc re-runs. - Deployments are optional but handy if you want to trigger these filtered re-runs from the UI/CLI on demand or on a schedule. If you already have a deployment, you can pass parameters via CLI:
Copy code
prefect deployment run my-flow/my-deployment -p only='["/users","/orders"]' --watch
- Deployments overview: Deployments UI note - There’s no one-click “retry this single task” after a flow run completes. The idiom is to start a new run that targets the subset of work you want, using parameters and caching to avoid redoing everything. If you can share how you’re structuring the endpoints today (e.g., a list you map over vs. individual tasks), I can tailor the pattern and code to your setup.
g
Abou the name of tasks. Im getting generic names like "download_tse_endpoint-252". How can i customize the name of the task to show on the UI?
@Marvin Abou the name of tasks. Im getting generic names like "download_tse_endpoint-252". How can i customize the name of the task to show on the UI?
m
✅ thought for 242.5 seconds
You can customize task names shown in the UI with the
@task
decorator: -
name=
sets the static task name (the node label) -
task_run_name=
sets the dynamic run name for each invocation (including mapped runs) Examples 1) Static task name + dynamic run name from parameters
Copy code
from prefect import task, flow

@task(
    name="Download TSE endpoint",
    task_run_name="Download {endpoint}"   # uses the task parameter names
)
def download_tse_endpoint(endpoint: str):
    ...

@flow
def sync(endpoints: list[str]):
    # Each mapped run is named "Download <endpoint>"
    download_tse_endpoint.map(endpoints)
2) Set/override per call with with_options
Copy code
download_tse_endpoint.with_options(
    task_run_name="Download {endpoint}"
).map(endpoints)
3) Use a callable for complex naming
Copy code
def run_name(params: dict) -> str:
    ep = params.get("endpoint", "unknown")
    return f"Download {ep}"

@task(task_run_name=run_name)
def download_tse_endpoint(endpoint: str):
    ...
Notes - The placeholders in
task_run_name
refer to the task’s parameter names (top-level fields). - Prefect may append a short suffix for uniqueness (e.g.,
-252
). You can’t remove it, but putting your informative part first makes names clear in the UI. - You can also set flow run names similarly:
@flow(flow_run_name="Sync {date}")
. References (implementation details) - resolve_custom_task_run_name: source - Task engine setting custom names: source - Task decorator definition: source If you share your current task signature (parameter names), I can suggest an exact
task_run_name
string for your case.
g
@Marvin About renaming the task name in my code i have the endpoints. I want to use the keys, that names the endpoints to use in the name of the task. Something like: fetch_tse_*candidatos_2018*: from pathlib import Path from prefect import task, get_run_logger from utils.io import download_stream from utils.br_data import BR_STATES, ELECTIONS_YEARS # MONTAR ENDPOINT REDES SOCIAIS POR ESTADO REDES_SOCIAIS_ENDPOINTS = { f"redes_sociais_{year}_{state}": f"https://cdn.tse.jus.br/estatistica/sead/odsele/consulta_cand/rede_social_candidato_{year}_{state}.zip" for state in BR_STATES for year in ELECTIONS_YEARS } TSE_ENDPOINTS = { "candidatos_2018": "https://cdn.tse.jus.br/estatistica/sead/odsele/consulta_cand/consulta_cand_2018.zip", "candidatos_2022": "https://cdn.tse.jus.br/estatistica/sead/odsele/consulta_cand/consulta_cand_2022.zip", "prestaca_contas_2018": "https://cdn.tse.jus.br/estatistica/sead/odsele/prestacao_contas/prestacao_de_contas_eleitorais_candidatos_2018.zip", "prestacao_contas_2022": "https://cdn.tse.jus.br/estatistica/sead/odsele/prestacao_contas/prestacao_de_contas_eleitorais_candidatos_2022.zip", "resultado_eleicao_2018": "https://cdn.tse.jus.br/estatistica/sead/odsele/votacao_candidato_munzona/votacao_candidato_munzona_2018.zip", "resultado_eleicao_2022": "https://cdn.tse.jus.br/estatistica/sead/odsele/votacao_candidato_munzona/votacao_candidato_munzona_2022.zip" } TSE_ENDPOINTS = TSE_ENDPOINTS | REDES_SOCIAIS_ENDPOINTS @task(retries=5, retry_delay_seconds=5, timeout_seconds=120, log_prints=True) def fetch_tse(name: str, url: str, out_dir: str = "data/tse") -> str: logger = get_run_logger() dest = Path(out_dir) / f"{name}.csv" logger.info(f"Fazendo download do endpoint TSE '{name}' -> {dest}") return download_stream(url, dest)
m
✅ thought for 36.5 seconds
Totally doable. Use
task_run_name
to format the run name from your
name
parameter so each run shows the endpoint key in the UI. Option A: set it on the task decorator
Copy code
from pathlib import Path
from prefect import task, get_run_logger

@task(
    name="Fetch TSE endpoint",                 # static node label
    task_run_name="fetch_tse_{name}",          # dynamic run name per call
    retries=5, retry_delay_seconds=5, timeout_seconds=120, log_prints=True
)
def fetch_tse(name: str, url: str, out_dir: str = "data/tse") -> str:
    logger = get_run_logger()
    dest = Path(out_dir) / f"{name}.csv"
    <http://logger.info|logger.info>(f"Fazendo download do endpoint TSE '{name}' -> {dest}")
    return download_stream(url, dest)
Then map over your dict so
name
and
url
align:
Copy code
from prefect import flow, unmapped

@flow
def sync_all():
    names, urls = zip(*TSE_ENDPOINTS.items())   # preserves pairing
    fetch_tse.map(name=names, url=urls, out_dir=unmapped("data/tse"))
Option B: set it at call-site with with_options (no change to decorator)
Copy code
from prefect import unmapped

names, urls = zip(*TSE_ENDPOINTS.items())
fetch_tse.with_options(task_run_name="fetch_tse_{name}").map(
    name=names, url=urls, out_dir=unmapped("data/tse")
)
Notes - The placeholders in
task_run_name
must match your task’s parameter names (
name
,
url
, etc.). Top-level keys only. - Prefect may append a short suffix for uniqueness (e.g.,
-252
), which is expected. - You can also use a callable for complex formatting:
Copy code
def rn(params: dict) -> str:
    return f"fetch_tse_{params['name']}"
fetch_tse = fetch_tse.with_options(task_run_name=rn)
Relevant internals if you’re curious: - Task decorator signature (supports
task_run_name
): source - Name resolution: resolve_custom_task_run_name
g
@Marvin This is in my main.py: from flows.pipeline import pipeline def main(): pipeline() if name == "__main__": main() This is the pipeline.py: from prefect import flow, get_run_logger from prefect.task_runners import ThreadPoolTaskRunner from datetime import date, datetime from tasks.tse import TSE_ENDPOINTS, extract_tse from tasks.camara.legislatura import extract_legislatura from tasks.camara.deputados import extract_deputados from tasks.camara.frentes import extract_frentes from config.loader import load_config APP_SETTINGS = load_config() # IMPORTAR TASKS TSE, CONGRESSO, SENADO ETC... @flow( task_runner=ThreadPoolTaskRunner(max_workers=APP_SETTINGS.FLOW.MAX_RUNNERS), # type: ignore log_prints=True ) def pipeline(date_today: date = datetime.now().date()): logger = get_run_logger() logger.info("Iniciando pipeline") # TSE: ~30 endpoints em paralelo tse_fs = [extract_tse.submit(name, url) for name, url in TSE_ENDPOINTS.items()] # CONGRESSO legislatura = extract_legislatura() deputados_f = extract_deputados.submit(legislatura) frentes_f = extract_frentes.submit(legislatura) # Reunindo os resultados tse_paths = [f.result() for f in tse_fs] deputados_path = deputados_f.result() frentes_path = frentes_f.result() logger.info(f"Arquivos TSE: {len(tse_paths)}") logger.info(f"Deputados -> {deputados_path}") logger.info(f"Frentes -> {frentes_path}") return { "tse": tse_paths, "congresso_deputados": deputados_path, "congresso_frentes": frentes_path } If i want to create a deploy should i create it in main?
m
✅ thought for 53.9 seconds
Short answer: don’t create the deployment in main. In Prefect 3.x you create a deployment for the flow function itself (your
pipeline
in
flows/pipeline.py
) and reference that entrypoint with the CLI. Suggested setup - Keep
main.py
for local dev runs only (your
if __name__ == "__main__": pipeline()
is fine). The deployment won’t use `main.py`; it imports your flow directly from
flows/pipeline.py
. Create a deployment from project root
Copy code
# From the root of your repo (where ./flows/pipeline.py exists)
prefect deploy ./flows/pipeline.py:pipeline -n pipeline-dev
Start a worker to pick up runs
Copy code
prefect worker start -p <your-work-pool>
Trigger the deployment
Copy code
# Run now and watch logs
prefect deployment run pipeline/pipeline-dev --watch

# Pass parameters if needed (example):
prefect deployment run pipeline/pipeline-dev -p date_today='"2025-10-21"'
Notes - The
ENTRYPOINT
format is
path/to/file.py:flow_function_name
(so:
./flows/pipeline.py:pipeline
). - You can create multiple deployments for the same flow (e.g., dev/prod, different work pools, schedules). - If you ever want to define a deployment in code (e.g., from a remote repo), use the Prefect 3.x pattern:
Copy code
from prefect import flow

flow.from_source(
    "<https://github.com/owner/repo>",
    entrypoint="flows/pipeline.py:pipeline",
).deploy(name="pipeline-dev", work_pool_name="<pool-name>")
Docs: Deployments in Prefect 3.x If you share your work pool name and how you plan to schedule it, I can give you an exact
prefect deploy
command tailored to your setup.
g
@Marvin given my pipeline.py, how it should be a simple deployment?
m
✅ thought for 81.4 seconds
Here’s a simple, minimal deployment for your
pipeline
flow in Prefect 3.x. Assumptions: - Your flow is defined at
./flows/pipeline.py
as
@flow def pipeline(...)
. - You want to run it on a local Process worker (default/simple option). Step 1) Create a work pool (once)
Copy code
prefect work-pool create "process-pool" --type process
Step 2) Create the deployment from your project root - The entrypoint format is
path/to/file.py:flow_function_name
Copy code
prefect deploy ./flows/pipeline.py:pipeline -n pipeline-dev
The CLI will prompt you for a work pool if not provided; choose
process-pool
. Step 3) Start a worker to pick up runs
Copy code
prefect worker start -p process-pool
Step 4) Run the deployment (and watch logs)
Copy code
prefect deployment run pipeline/pipeline-dev --watch
Pass parameters (e.g., your
date_today
flow parameter) if needed:
Copy code
prefect deployment run pipeline/pipeline-dev -p date_today='"2025-10-22"' --watch
Notes: - You don’t deploy `main.py`; deployments target the flow entrypoint directly (
./flows/pipeline.py:pipeline
). Keep
main.py
for local dev runs if you like. - Your
ThreadPoolTaskRunner
in the flow is fine; it will be honored when the flow runs from a deployment. - You can add a schedule later with
--cron
,
--interval
, etc., or via the UI. Docs: - Deployments: Prefect 3.x deployments - Work pools and workers: Work pools and workers
g
@Marvin Should i do this on pipeline.py? if name == "__main__": pipeline.serve( # type: ignore name="deploy-1" ) Im running with uv run .\src\main.py command but deployment istn working.
m
✅ thought for 237.4 seconds
Short answer: don’t put serve() in pipeline.py if you’re running main.py. The main block in pipeline.py won’t execute when you start main.py. Use one of these: Option A: Serve (embedded runner, no worker needed) - Put serve in main.py so it actually runs:
Copy code
from flows.pipeline import pipeline

if __name__ == "__main__":
    # Creates a deployment and starts a local runner that polls for runs
    pipeline.serve(
        name="deploy-1",
        # optional: schedule it
        # interval=3600,  # run every hour
        # or cron="0 7 * * *",
    )
Then start it:
Copy code
uv run src/main.py
Notes: - serve() blocks and keeps running. It registers the deployment and polls for scheduled/manual runs. - To run it immediately without a schedule, trigger it from another terminal or the UI:
Copy code
prefect deployment run pipeline/deploy-1 --watch
- No worker/work pool required for serve; the process running serve executes the runs. - By default, the deployment pauses on shutdown; if you want it to stay active after the process ends, set
pause_on_shutdown=False
. Option B: Classic deployment + worker - Create a work pool and deployment, then start a worker:
Copy code
prefect work-pool create process-pool --type process
prefect deploy ./flows/pipeline.py:pipeline -n deploy-1
prefect worker start -p process-pool
prefect deployment run pipeline/deploy-1 --watch
Common gotchas - Calling
pipeline()
in main.py executes the flow directly and bypasses deployments. Use
pipeline.serve(...)
(Option A) or create a deployment via CLI (Option B). - If you put
if __name__ == "__main__": pipeline.serve(...)
in pipeline.py, you must run that file directly for it to execute; running main.py won’t trigger pipeline.py’s main block. - If serve is running but “nothing happens,” you likely haven’t scheduled or triggered a run yet. Use a schedule in serve() or
prefect deployment run ...
. Docs - Serve flows (embedded runner): Run flows in local processes - Create deployments: Create deployments If you tell me which option you prefer (serve vs worker), I’ll tailor the exact commands and file changes for your setup.
g
@Marvin I want to define the value of cache_policy in my TOML config file: from pathlib import Path from prefect import task, get_run_logger from prefect.cache_policies import INPUTS from utils.io import download_stream from utils.br_data import BR_STATES, ELECTIONS_YEARS from config import loader APP_SETTINGS = loader.load_config() # MONTAR ENDPOINT REDES SOCIAIS POR ESTADO REDES_SOCIAIS_ENDPOINTS = { f"redes_sociais_{year}_{state}": f"{APP_SETTINGS.TSE.BASE_URL}consulta_cand/rede_social_candidato_{year}_{state}.zip" for state in BR_STATES for year in ELECTIONS_YEARS } TSE_ENDPOINTS = { "candidatos_2018": f"{APP_SETTINGS.TSE.BASE_URL}consulta_cand/consulta_cand_2018.zip", "candidatos_2022": f"{APP_SETTINGS.TSE.BASE_URL}consulta_cand/consulta_cand_2022.zip", "prestaca_contas_2018": f"{APP_SETTINGS.TSE.BASE_URL}prestacao_contas/prestacao_de_contas_eleitorais_candidatos_2018.zip", "prestacao_contas_2022": f"{APP_SETTINGS.TSE.BASE_URL}prestacao_contas/prestacao_de_contas_eleitorais_candidatos_2022.zip", "resultado_eleicao_2018": f"{APP_SETTINGS.TSE.BASE_URL}votacao_candidato_munzona/votacao_candidato_munzona_2018.zip", "resultado_eleicao_2022": f"{APP_SETTINGS.TSE.BASE_URL}votacao_candidato_munzona/votacao_candidato_munzona_2022.zip" } TSE_ENDPOINTS = TSE_ENDPOINTS | REDES_SOCIAIS_ENDPOINTS @task( retries=APP_SETTINGS.TSE.RETRIES, retry_delay_seconds=APP_SETTINGS.TSE.RETRY_DELAY, timeout_seconds=APP_SETTINGS.TSE.TIMEOUT, log_prints=True, cache_policy=INPUTS ) def extract_tse(name: str, url: str, out_dir: str = "data/tse") -> str: logger = get_run_logger() dest = Path(out_dir) / f"{name}.csv" logger.info(f"Fazendo download do endpoint TSE '{name}' -> {dest}") return download_stream(url, dest) But how can i do it if the value of inptus comes from from prefect.cache_policies import INPUTS?
m
✅ thought for 47.8 seconds
Great question. Since TOML can’t import Python symbols, the usual pattern is: - Store a simple string in your TOML (e.g., "INPUTS", "NONE", "DEFAULT", etc.) - In your config loader, map that string to the actual Prefect object (e.g., the
INPUTS
constant or a
CachePolicy()
instance) - Use the mapped object in your
@task(cache_policy=...)
Example TOML (e.g., config.toml)
Copy code
[tse]
retries = 5
retry_delay = 5
timeout = 120
cache_policy = "INPUTS"          # or "NONE", "DEFAULT", "FLOW_PARAMETERS", "TASK_SOURCE", "RUN_ID"
cache_expiration_seconds = 86400 # optional, if you also set cache_expiration
Config loader mapping
Copy code
from datetime import timedelta
from prefect.cache_policies import (
    CachePolicy,
    INPUTS,
    NONE,          # aka NO_CACHE in some contexts
    DEFAULT,
    TASK_SOURCE,
    FLOW_PARAMETERS,
    RUN_ID,
)

_CACHE_POLICY_MAP = {
    "INPUTS": INPUTS,
    "NONE": NONE,
    "NO_CACHE": NONE,
    "DEFAULT": DEFAULT,
    "TASK_SOURCE": TASK_SOURCE,
    "FLOW_PARAMETERS": FLOW_PARAMETERS,
    "RUN_ID": RUN_ID,
}

def coerce_cache_policy(policy_name: str | None):
    if policy_name is None:
        return None  # means "NotSet" if you don’t pass cache_policy
    if isinstance(policy_name, str):
        key = policy_name.strip().upper()
        if key in _CACHE_POLICY_MAP:
            return _CACHE_POLICY_MAP[key]
        raise ValueError(f"Unknown cache_policy '{policy_name}'. "
                         f"Expected one of: {', '.join(_CACHE_POLICY_MAP)}")
    # You could also allow pre-built CachePolicy objects here if needed
    raise TypeError(f"cache_policy must be a string or None, got {type(policy_name)}")
Use in your settings/model
Copy code
class TSEConfig(BaseModel):
    RETRIES: int
    RETRY_DELAY: int
    TIMEOUT: int
    CACHE_POLICY: str | None = None
    CACHE_EXPIRATION_SECONDS: int | None = None

    @property
    def cache_policy_obj(self):
        return coerce_cache_policy(self.CACHE_POLICY)

    @property
    def cache_expiration_td(self):
        return timedelta(seconds=self.CACHE_EXPIRATION_SECONDS) if self.CACHE_EXPIRATION_SECONDS else None
Task usage
Copy code
APP_SETTINGS = loader.load_config()

@task(
    retries=APP_SETTINGS.TSE.RETRIES,
    retry_delay_seconds=APP_SETTINGS.TSE.RETRY_DELAY,
    timeout_seconds=APP_SETTINGS.TSE.TIMEOUT,
    log_prints=True,
    cache_policy=APP_SETTINGS.TSE.cache_policy_obj,
    cache_expiration=APP_SETTINGS.TSE.cache_expiration_td,
    task_run_name="fetch_tse_{name}",
)
def extract_tse(name: str, url: str, out_dir: str = "data/tse") -> str:
    logger = get_run_logger()
    dest = Path(out_dir) / f"{name}.csv"
    <http://logger.info|logger.info>(f"Fazendo download do endpoint TSE '{name}' -> {dest}")
    return download_stream(url, dest)
Notes -
task(cache_policy=...)
expects a
CachePolicy
or one of the provided constants (e.g.,
INPUTS
). Mapping strings from TOML to those objects is the safest approach. - If you need a fully custom
CachePolicy
(e.g., specific
key_storage
or
isolation_level
), put the parameters in TOML and build a
CachePolicy(...)
in your loader when
cache_policy = "CUSTOM"
. - Caching docs (for more detail): Result caching in Prefect 3.x If you share your loader’s structure, I can fold the mapping logic directly into it.
g
@Marvin i want to cache the results of downloaded large size files, more than 1gb. Is this ok?
m
✅ thought for 47.4 seconds
Yes—with a couple of important caveats it’s a good fit. What “caching” means here - Prefect caches the task’s return value, not the file contents. If your task returns a small string (e.g., the destination path or S3 URI), caching is efficient even when the actual file is >1GB. Recommendations for large files - Return a small value: return the file path/URI (e.g.,
"data/tse/candidatos_2018.csv"
or
"<s3://bucket/key.csv>"
), not the bytes. - Keep
cache_policy=INPUTS
so identical inputs skip re-downloads. Consider adding
cache_expiration
to bound staleness. - Ensure results are persisted so the cache can be reused across runs. Either: - set
persist_result=True
on the task, or - enable globally with
PREFECT_RESULTS_PERSIST_BY_DEFAULT=true
. - Make the file location durable: - If you run everything on one machine (serve or a single worker), a local path is fine. - If you run on multiple machines/workers, store the file in shared storage (e.g., S3/GCS) and return that URI so downstream steps can access it anywhere. Practical pattern
Copy code
from datetime import timedelta
from pathlib import Path
from prefect import task, get_run_logger
from prefect.cache_policies import INPUTS

@task(
    retries=APP_SETTINGS.TSE.RETRIES,
    retry_delay_seconds=APP_SETTINGS.TSE.RETRY_DELAY,
    timeout_seconds=APP_SETTINGS.TSE.TIMEOUT,
    log_prints=True,
    persist_result=True,                  # make cache reusable across runs
    cache_policy=INPUTS,                  # cache by inputs (name, url, out_dir)
    cache_expiration=timedelta(days=30),  # optional TTL for freshness
    task_run_name="fetch_tse_{name}",
)
def extract_tse(name: str, url: str, out_dir: str = "data/tse") -> str:
    logger = get_run_logger()
    dest = Path(out_dir) / f"{name}.csv"

    # Local idempotency guard: if file already exists, skip network download
    if dest.exists() and dest.stat().st_size > 0:
        <http://logger.info|logger.info>(f"Arquivo jĂĄ existe, reutilizando: {dest}")
        return str(dest)

    <http://logger.info|logger.info>(f"Fazendo download do endpoint TSE '{name}' -> {dest}")
    return download_stream(url, dest)
Optional: smarter cache busting - If the remote file can change at the same URL, you can include a version signal (e.g., ETag or Last-Modified) in the cache key via
cache_key_fn
. That way a changed upstream invalidates the cache. Trade-offs to be aware of - A cached success does not verify the file still exists on disk. If the file gets deleted, the cached run will still “succeed.” The existence check above mitigates this. - If you use multiple workers, a local path returned from one machine won’t exist on another. Prefer object storage for the actual file and return the object URI. Docs - Result caching - Result persistence and storage If you share whether you’re running on a single machine or multiple workers (and whether you have S3/GCS), I can tailor the storage/result settings exactly.
g
@Marvin See my code: from pathlib import Path from prefect import task, get_run_logger from datetime import timedelta from utils.io import download_stream from utils.br_data import BR_STATES, ELECTIONS_YEARS from config.loader import load_config, CACHE_POLICY_MAP APP_SETTINGS = load_config() # MONTAR ENDPOINT REDES SOCIAIS POR ESTADO REDES_SOCIAIS_ENDPOINTS = { f"redes_sociais_{year}_{state}": f"{APP_SETTINGS.TSE.BASE_URL}consulta_cand/rede_social_candidato_{year}_{state}.zip" for state in BR_STATES for year in ELECTIONS_YEARS } TSE_ENDPOINTS = { "candidatos_2018": f"{APP_SETTINGS.TSE.BASE_URL}consulta_cand/consulta_cand_2018.zip", "candidatos_2022": f"{APP_SETTINGS.TSE.BASE_URL}consulta_cand/consulta_cand_2022.zip", "prestaca_contas_2018": f"{APP_SETTINGS.TSE.BASE_URL}prestacao_contas/prestacao_de_contas_eleitorais_candidatos_2018.zip", "prestacao_contas_2022": f"{APP_SETTINGS.TSE.BASE_URL}prestacao_contas/prestacao_de_contas_eleitorais_candidatos_2022.zip", "resultado_eleicao_2018": f"{APP_SETTINGS.TSE.BASE_URL}votacao_candidato_munzona/votacao_candidato_munzona_2018.zip", "resultado_eleicao_2022": f"{APP_SETTINGS.TSE.BASE_URL}votacao_candidato_munzona/votacao_candidato_munzona_2022.zip" } TSE_ENDPOINTS = TSE_ENDPOINTS | REDES_SOCIAIS_ENDPOINTS @task( retries=APP_SETTINGS.TSE.RETRIES, retry_delay_seconds=APP_SETTINGS.TSE.RETRY_DELAY, timeout_seconds=APP_SETTINGS.TSE.TIMEOUT, log_prints=True, cache_policy=CACHE_POLICY_MAP[APP_SETTINGS.TSE.CACHE_POLICY], cache_expiration=timedelta(days=APP_SETTINGS.TSE.CACHE_EXPIRATION) ) def extract_tse(name: str, url: str, out_dir: str = "data/tse") -> str: logger = get_run_logger() dest = Path(out_dir) / f"{name}.csv" logger.info(f"Fazendo download do endpoint TSE '{name}' -> {dest}") return download_stream(url, dest) *What i want is have a custom name for the task so i can distinguish each task easily on the UI. For example, currently i have something like "*extract_tse-34b" but i want the name of the key string in the list used to download the files, like "*candidatos_2018" so it would be "extract_tse_candidatos_2018-34b"*
m
✅ thought for 29.6 seconds
Yes — you can set the task run name from the
name
parameter so each run shows the key in the UI. Add
task_run_name="extract_tse_{name}"
to your task decorator:
Copy code
from pathlib import Path
from prefect import task, get_run_logger
from datetime import timedelta

from <http://utils.io|utils.io> import download_stream
from config.loader import load_config, CACHE_POLICY_MAP

APP_SETTINGS = load_config()

@task(
    retries=APP_SETTINGS.TSE.RETRIES,
    retry_delay_seconds=APP_SETTINGS.TSE.RETRY_DELAY,
    timeout_seconds=APP_SETTINGS.TSE.TIMEOUT,
    log_prints=True,
    cache_policy=CACHE_POLICY_MAP[APP_SETTINGS.TSE.CACHE_POLICY],
    cache_expiration=timedelta(days=APP_SETTINGS.TSE.CACHE_EXPIRATION),
    task_run_name="extract_tse_{name}",   # <- dynamic run name per invocation
)
def extract_tse(name: str, url: str, out_dir: str = "data/tse") -> str:
    logger = get_run_logger()
    dest = Path(out_dir) / f"{name}.csv"
    <http://logger.info|logger.info>(f"Fazendo download do endpoint TSE '{name}' -> {dest}")
    return download_stream(url, dest)
Then, when you call/submit the task like:
Copy code
# inside your flow
for key, url in TSE_ENDPOINTS.items():
    extract_tse.submit(name=key, url=url)
each task run will appear as
extract_tse_<key>-<suffix>
, e.g.,
extract_tse_candidatos_2018-34b
. Notes - The placeholders in
task_run_name
must match the task’s parameter names (
name
,
url
, ...). Here
{name}
is the dict key you’re passing. - Prefect appends a short suffix (e.g.,
-34b
) for uniqueness — that’s expected. - If you prefer to set this at call time (without changing the decorator), you can do:
Copy code
extract_tse.with_options(task_run_name="extract_tse_{name}").submit(name=key, url=url)
For complex formatting/truncation, you can use a callable:
Copy code
def rn(params: dict) -> str:
    raw = params["name"]
    short = raw[:60]
    return f"extract_tse_{short}"

extract_tse = extract_tse.with_options(task_run_name=rn)
g
@Marvin Ok it worked! Now i want a more complex logic to cache. I have some endpoints where i download the data based on the year. So i would download the CSV from 2023, 2024 and 2025. Since 2023 and 2024 are already past i would need it only the CSV from the current year. How could i cache only the CSVs from the past years?
m
✅ thought for 68.0 seconds
Yes — you can make caching conditional on the year. There are two straightforward patterns in Prefect 3.x: Pattern 1: Choose options per call with with_options - Cache past years (long TTL) - Disable caching for the current year
Copy code
from datetime import date, timedelta
import re

CURRENT_YEAR = date.today().year

def extract_year_from_name(name: str) -> int | None:
    # e.g., "candidatos_2018", "redes_sociais_2024_SP"
    m = re.search(r"\b(20\d{2})\b", name)
    return int(m.group(1)) if m else None

def cache_opts_for_year(year: int | None):
    if year is None:
        # no year in name -> default behavior
        return {}
    if year < CURRENT_YEAR:
        # cache past years
        return {
            "cache_policy": CACHE_POLICY_MAP["INPUTS"],
            "cache_expiration": timedelta(days=365*5),
            "persist_result": True,
        }
    else:
        # do not cache current year
        return {
            "cache_policy": CACHE_POLICY_MAP["NO_CACHE"],
            # OR: "refresh_cache": True,
        }

# inside your flow
for name, url in TSE_ENDPOINTS.items():
    year = extract_year_from_name(name)
    opts = cache_opts_for_year(year)
    extract_tse.with_options(**opts).submit(name=name, url=url)
Pattern 2: Pass year as an explicit parameter If you already know the year, add it to the task signature to avoid parsing from the name:
Copy code
@task(
    # defaults here are fine — we’ll override per call
    cache_policy=CACHE_POLICY_MAP[APP_SETTINGS.TSE.CACHE_POLICY],
    cache_expiration=timedelta(days=APP_SETTINGS.TSE.CACHE_EXPIRATION),
    task_run_name="extract_tse_{name}",
)
def extract_tse(name: str, url: str, year: int | None = None, out_dir: str = "data/tse") -> str:
    ...
Then:
Copy code
for name, url in TSE_ENDPOINTS.items():
    year = extract_year_from_name(name)
    opts = cache_opts_for_year(year)
    extract_tse.with_options(**opts).submit(name=name, url=url, year=year)
Notes - NO_CACHE vs refresh_cache: - NO_CACHE cleanly disables caching for that call. - refresh_cache=True forces bypass even if a cache exists; useful if you want to “always re-download” for current year. - Keep returning a small value (the file path/URI), not the file bytes. - Add a local existence guard to skip re-downloads regardless of Prefect cache: - if the file already exists and is non-empty, just return the path. - If you run on multiple machines, store files in shared/object storage and return the URI. Config suggestion (TOML) - You can keep your global defaults and still override per-call:
Copy code
[tse]
cache_policy = "INPUTS"              # default for past years
cache_expiration = 3650              # 10 years
no_cache_policy = "NO_CACHE"         # used for current year
Then in loader:
Copy code
def cache_opts_for_year(year):
    if year is None or year < CURRENT_YEAR:
        return {
            "cache_policy": CACHE_POLICY_MAP[APP_SETTINGS.TSE.CACHE_POLICY],
            "cache_expiration": timedelta(days=APP_SETTINGS.TSE.CACHE_EXPIRATION),
            "persist_result": True,
        }
    else:
        return {
            "cache_policy": CACHE_POLICY_MAP[APP_SETTINGS.TSE.NO_CACHE_POLICY],
        }
Docs: result caching in Prefect 3.x - Result caching If you share your naming scheme for all endpoints, I can provide an exact
extract_year_from_name
tailored to your keys.
g
@Marvin If a Deployment is running and i change my appsettings config cache i need to restart de deployment. [TSE] BASE_URL = "https://cdn.tse.jus.br/estatistica/sead/odsele/" RETRIES = 5 RETRY_DELAY = 5 # Segundos TIMEOUT = 120 # Segundos CACHE_POLICY = "NO_CACHE" CACHE_EXPIRATION = 90 # Dias Is there a pattern to deal with this?
m
✅ thought for 275.2 seconds
Short answer: it depends on how you run the deployment and where you read config. - If you’re using flow.serve: that process is long‑lived. Since your code does
APP_SETTINGS = load_config()
at import time, changes in TOML won’t be picked up until you restart the serve process. Pattern: load config at run time (inside the flow) instead of at import, or restart serve after changing config. - If you’re using deploy + worker: each flow run starts in a fresh process, so if your code reads the TOML at run time, new runs will pick up the change without redeploy/restart. If you bake config into a Docker image, you’ll need to rebuild or mount the config. Best-practice patterns to avoid restarts/redeploys 1) Load config at runtime, not at import - Move
APP_SETTINGS = load_config()
into your flow function (or a small helper that’s called at the start of each run). This ensures each run reads the latest config. - Avoid baking config into decorators. In your code,
cache_policy=CACHE_POLICY_MAP[APP_SETTINGS.TSE.CACHE_POLICY]
is evaluated at import time and “freezes” that value. Prefer to set task options per call with
with_options(...)
. 2) Make cache policy dynamic per run - Remove cache-related arguments from the decorator and set them at submit time, using the config you load at the start of the flow.
Copy code
from datetime import timedelta
from prefect import task, flow
from prefect.cache_policies import INPUTS, NO_CACHE

@task(
    retries=5,
    retry_delay_seconds=5,
    timeout_seconds=120,
    log_prints=True,
    task_run_name="extract_tse_{name}",
)
def extract_tse(name: str, url: str, out_dir: str="data/tse") -> str:
    ...

@flow
def pipeline(...):
    settings = load_config()  # read TOML per run
    cache_policy = CACHE_POLICY_MAP[settings.TSE.CACHE_POLICY]  # e.g. "NO_CACHE" -> NO_CACHE
    cache_exp = timedelta(days=settings.TSE.CACHE_EXPIRATION)

    for name, url in TSE_ENDPOINTS.items():
        extract_tse.with_options(
            cache_policy=cache_policy,
            cache_expiration=cache_exp,
            persist_result=True,   # if you want cross-run reuse
        ).submit(name=name, url=url)
- Now you can change the TOML and the next run will use the new values, even with a running worker. For flow.serve, this also works as long as you read config in the flow, not at import. 3) Use flow parameters to override per run - Expose caching knobs as flow parameters and set them when you trigger a run. No redeploy needed for per‑run overrides:
Copy code
@flow
def pipeline(cache_policy: str = "NO_CACHE", cache_expiration_days: int = 90):
    cache_policy_obj = CACHE_POLICY_MAP[cache_policy]
    cache_exp = timedelta(days=cache_expiration_days)
    ...
Trigger with:
Copy code
prefect deployment run pipeline/deploy-1 -p cache_policy='"INPUTS"' -p cache_expiration_days=365 --watch
4) Use Prefect Variables or job variables for runtime config - Prefect Variables: set in UI or via API, then read at runtime. Change anytime; no redeploy.
Copy code
from prefect.variables import Variable
policy_name = Variable.get("TSE_CACHE_POLICY", default="NO_CACHE")
exp_days = int(Variable.get("TSE_CACHE_EXPIRATION_DAYS", default="90"))
Docs: Prefect Variables - Job variables: pass env vars per run that your loader reads:
Copy code
prefect deployment run pipeline/deploy-1 \
  -jv TSE_CACHE_POLICY='"INPUTS"' \
  -jv TSE_CACHE_EXPIRATION_DAYS=365 \
  --watch
Docs: Customize job variables When you DO need a restart or redeploy - serve(): restart the process if config is loaded at import time or if you changed code. If you move config reads inside the flow, you don’t need to restart for TOML changes.
- If you changed task decorator arguments (e.g., you edited
@task(cache_policy=...)
in code), that’s a code change. For worker-based deployments, new runs pick it up automatically if you’re running from source. For Docker/K8s images or pinned-source deployments, rebuild/redeploy. - If your deployment pins parameters or a specific image/revision, changing those defaults requires a new deployment version. In short: refactor to read config at run time and set task options with
with_options(...)
or flow parameters. Then you won’t need to restart the deployment to apply caching changes.
g
@Marvin RunnerSettings Settings for controlling runner behavior
process_limit
Maximum number of processes a runner will execute in parallel. Type: `integer` Default: `5` TOML dotted key path: `runner.process_limit` Supported environment variables: `PREFECT_RUNNER_PROCESS_LIMIT` Setting this on my prefect.toml will make my task runner in my flow: @flow( task_runner=ThreadPoolTaskRunner(max_workers=16), # type: ignore log_prints=True ) follow whats defined in the toml?
m
✅ thought for 40.2 seconds
Short answer: No.
runner.process_limit
does not change your flow’s
ThreadPoolTaskRunner(max_workers=16)
. What each knob controls -
runner.process_limit
(setting or
PREFECT_RUNNER_PROCESS_LIMIT
): caps how many flow runs the embedded Runner will execute in parallel when you use
flow.serve(...)
. It’s about concurrent flow runs, not tasks within a flow. Docs: process_limit - `ThreadPoolTaskRunner(max_workers=...)`: controls task-level concurrency inside a single flow run. This is set by your code and is not overridden by
runner.process_limit
. Implications - If you’re using
flow.serve
, you can have: - up to
runner.process_limit
concurrent flow runs in that one serve process - and inside each flow run, up to
max_workers
concurrent tasks (your 16) - If you’re using worker-based deployments (prefect deploy + worker),
runner.process_limit
does not apply. Workers typically execute one flow run per process; scale by running more workers or using work pool concurrency limits. If you want to make
max_workers
configurable via TOML, read it in your own config and pass it to the task runner:
Copy code
from prefect import flow
from prefect.task_runners import ThreadPoolTaskRunner

APP_SETTINGS = load_config()

@flow(
    task_runner=ThreadPoolTaskRunner(
        max_workers=APP_SETTINGS.FLOW.MAX_RUNNERS  # from your TOML
    ),
    log_prints=True,
)
def pipeline(...):
    ...
Useful links - Runner (serve) behavior: Run flows in local processes - process_limit setting: Runner process_limit
g
@Marvin
@Marvin How to clear the cache from my results?
m
✅ thought for 299.1 seconds
There isn’t a single “clear cache” button in Prefect 3.x, but you have several reliable ways to invalidate or bypass cached results. Use the one that fits your scenario: Quick ways to force fresh runs - Per-task, one-off: - Set
refresh_cache=True
on the task (decorator or via
with_options
) to bypass and overwrite any existing cache for that task. - Example:
Copy code
extract_tse.with_options(refresh_cache=True).submit(name=..., url=...)
- Globally for a run: - Set env var
PREFECT_TASKS_REFRESH_CACHE=true
for the process running your flow. All tasks will refresh their cache. Disable caching - Per-task call: - Use
cache_policy=NO_CACHE
via
with_options(...)
for a specific run:
Copy code
from prefect.cache_policies import NO_CACHE
  extract_tse.with_options(cache_policy=NO_CACHE).submit(...)
- Globally: - Set
PREFECT_TASKS_DEFAULT_NO_CACHE=true
or
PREFECT_TASKS_DISABLE_CACHING=true
in the environment to disable caching across tasks. Expire or invalidate by configuration - cache_expiration: Reduce TTL so caches expire sooner:
Copy code
extract_tse.with_options(cache_expiration=timedelta(days=1)).submit(...)
- Change inputs or code: With the DEFAULT/INPUTS policy, changing task inputs or the task’s source code will produce a new cache key automatically. Delete stored cache/results - Local default result store is typically at
~/.prefect/storage/
(can be changed via
PREFECT_LOCAL_STORAGE_PATH
). Deleting entries here removes stored results and cache records. This is the blunt instrument—safe but removes everything. - If you configured a custom
CachePolicy(key_storage=...)
or remote result storage (S3/GCS), clear the corresponding keys/objects there to invalidate caches. - There’s no built-in CLI like
prefect cache clear
. If needed, you can script deletion. Operational tips - If you’re using flow.serve, remember it’s a long-running process. Environment variable changes apply when you restart that process. - For worker-based deployments, each run is a fresh process. Env vars and code changes take effect on the next run. - Prefer targeted invalidation (refresh_cache or NO_CACHE) over deleting the entire store unless you truly want to wipe all caches. Docs for reference - Caching concepts: Caching - Advanced caching: Advanced caching - Results and storage: Results If you tell me whether you’re running locally with serve or via workers, and where your results are stored (local vs S3/GCS), I can give you exact commands/steps to clear them.
g
@Marvin I got run_logger on my tasks: from pathlib import Path from prefect import task, get_run_logger from utils.io import fetch_json, save_json from config.loader import load_config APP_SETTINGS = load_config() def frentes_url(legislatura: dict) -> str: id_legislatura = legislatura["dados"][0]["id"] return f"{APP_SETTINGS.CAMARA.REST_BASE_URL}/frentes?idLegislatura={id_legislatura}" @task( retries=APP_SETTINGS.CAMARA.RETRIES, retry_delay_seconds=APP_SETTINGS.CAMARA.RETRY_DELAY, timeout_seconds=APP_SETTINGS.CAMARA.TIMEOUT ) def extract_frentes(legislatura: dict, out_dir: str = "data/camara") -> str: logger = get_run_logger() url = frentes_url(legislatura) dest = Path(out_dir) / "frentes.json" logger.info(f"Congresso: buscando Frentes de {url} -> {dest}") json = fetch_json(url) return save_json(json, dest) Do i need it if i set [logging] level = "INFO" log_prints = "True" on my prefect.toml?
m
✅ thought for 175.0 seconds
Short answer: you don’t need
get_run_logger()
if you’re happy using
print()
and you’ve enabled print-capture correctly. With log_prints on,
print()
output is sent to Prefect logs. Key points: - Your current code uses
<http://logger.info|logger.info>(...)
. That does require
get_run_logger()
. The
log_prints
setting does not create a logger; it only captures `print()`/stdout/stderr. - If you want to drop
get_run_logger()
, replace the log line with
print(...)
and keep
log_prints
enabled. Fix your prefect.toml (TOML booleans are unquoted and lowercase):
Copy code
[logging]
level = "INFO"
log_prints = true
If you leave it as
log_prints = "True"
, it won’t enable print capture. When to prefer each: - Use
get_run_logger()
when you want structured, Prefect-aware logs (correct run/task context, log levels, works even if
log_prints
is off). - Use
print()
if you just want simple messages and have
log_prints = true
. Per-task override is also available:
Copy code
@task(log_prints=True)
def my_task():
    print("this will be captured even if global log_prints is false")
More details: - Add logging to flows/tasks: Add logging - Advanced logging (levels, extra loggers for third‑party libraries): Advanced logging customization - Settings reference (including
logging.log_prints
): Settings reference
g
@Marvin from pathlib import Path from prefect import task, get_run_logger from typing import cast from utils.io import fetch_json_many_async, save_ndjson from config.loader import load_config APP_SETTINGS = load_config() def frentes_membros_urls(frentes_ids: list[str]) -> list[str]: return [f"{APP_SETTINGS.CAMARA.REST_BASE_URL}frentes/{id}/membros" for id in frentes_ids] @task( retries=APP_SETTINGS.CAMARA.RETRIES, retry_delay_seconds=APP_SETTINGS.CAMARA.RETRY_DELAY, timeout_seconds=APP_SETTINGS.CAMARA.TIMEOUT ) def extract_frentes_membros(frentes_ids: list[str], out_dir: str | Path = "data/camara") -> str: logger = get_run_logger() urls = frentes_membros_urls(frentes_ids) logger.info(f"Congresso: buscando Membros de {len(urls)} Frentes") jsons = fetch_json_many_async( urls, concurrency=APP_SETTINGS.CAMARA.CONCURRENCY, timeout=APP_SETTINGS.CAMARA.TIMEOUT, follow_pagination=True ) dest = Path(out_dir) return save_ndjson(cast(list[dict], jsons), dest) fetch_json_many_async IS ASYNC. Do i need to set async await in the extract_frentes_membros or do i need to set something when calling the function in the pipeline?
m
✅ thought for 395.6 seconds
Great question. Since
fetch_json_many_async
is async, you must either await it or run it via a sync bridge. You have two good options: Option A — Make the task async and await inside
Copy code
from pathlib import Path
from typing import cast
from prefect import task, get_run_logger

from <http://utils.io|utils.io> import fetch_json_many_async, save_ndjson
from config.loader import load_config

APP_SETTINGS = load_config()

def frentes_membros_urls(frentes_ids: list[str]) -> list[str]:
    return [f"{APP_SETTINGS.CAMARA.REST_BASE_URL}frentes/{id}/membros" for id in frentes_ids]

@task(
    retries=APP_SETTINGS.CAMARA.RETRIES,
    retry_delay_seconds=APP_SETTINGS.CAMARA.RETRY_DELAY,
    timeout_seconds=APP_SETTINGS.CAMARA.TIMEOUT,
)
async def extract_frentes_membros(frentes_ids: list[str], out_dir: str | Path = "data/camara") -> str:
    logger = get_run_logger()
    urls = frentes_membros_urls(frentes_ids)
    <http://logger.info|logger.info>(f"Congresso: buscando Membros de {len(urls)} Frentes")

    jsons = await fetch_json_many_async(
        urls,
        concurrency=APP_SETTINGS.CAMARA.CONCURRENCY,
        timeout=APP_SETTINGS.CAMARA.TIMEOUT,
        follow_pagination=True,
    )
    dest = Path(out_dir)
    return save_ndjson(cast(list[dict], jsons), dest)
How to call it: - In an async flow, you can either: - Run concurrently:
fut = extract_frentes_membros.submit(...); result = fut.result()
- Or directly await:
result = await extract_frentes_membros(...)
Option B — Keep the task sync and bridge the coroutine
Copy code
from pathlib import Path
from typing import cast
from prefect import task, get_run_logger
from prefect.utilities.asyncutils import run_coro_as_sync

from <http://utils.io|utils.io> import fetch_json_many_async, save_ndjson
from config.loader import load_config

APP_SETTINGS = load_config()

def frentes_membros_urls(frentes_ids: list[str]) -> list[str]:
    return [f"{APP_SETTINGS.CAMARA.REST_BASE_URL}frentes/{id}/membros" for id in frentes_ids]

@task(
    retries=APP_SETTINGS.CAMARA.RETRIES,
    retry_delay_seconds=APP_SETTINGS.CAMARA.RETRY_DELAY,
    timeout_seconds=APP_SETTINGS.CAMARA.TIMEOUT,
)
def extract_frentes_membros(frentes_ids: list[str], out_dir: str | Path = "data/camara") -> str:
    logger = get_run_logger()
    urls = frentes_membros_urls(frentes_ids)
    <http://logger.info|logger.info>(f"Congresso: buscando Membros de {len(urls)} Frentes")

    jsons = run_coro_as_sync(
        fetch_json_many_async(
            urls,
            concurrency=APP_SETTINGS.CAMARA.CONCURRENCY,
            timeout=APP_SETTINGS.CAMARA.TIMEOUT,
            follow_pagination=True,
        )
    )
    dest = Path(out_dir)
    return save_ndjson(cast(list[dict], jsons), dest)
Notes: - Don’t call the async function without awaiting/bridging; it will just return a coroutine and not execute. - You do not need to set anything special when calling the task from the flow. Use
.submit()
(returns a PrefectFuture; call
.result()
to get the value) or call the task directly depending on whether your flow is async and whether you want concurrency. References: - Tasks and async patterns: Tasks concept - Running work concurrently with submit/result: Run work concurrently - Async utilities (
run_coro_as_sync
): Async utilities API
g
@Marvin this is how im doing in the pipeline. Show me following whats being done: from prefect import flow, get_run_logger, serve from prefect.task_runners import ThreadPoolTaskRunner from datetime import date, datetime from tasks.tse import TSE_ENDPOINTS, extract_tse from tasks.camara.legislatura import extract_legislatura from tasks.camara.deputados import extract_deputados from tasks.camara.frentes import extract_frentes from config.loader import load_config APP_SETTINGS = load_config() # IMPORTAR TASKS TSE, CONGRESSO, SENADO ETC... @flow( task_runner=ThreadPoolTaskRunner(max_workers=APP_SETTINGS.FLOW.MAX_RUNNERS), # type: ignore log_prints=True ) def pipeline( date: date = datetime.now().date(), refresh_cache: bool = False ): logger = get_run_logger() logger.info("Iniciando pipeline") # TSE: ~30 endpoints em paralelo tse_fs = [ extract_tse .with_options(refresh_cache=refresh_cache) .submit(name, url) for name, url in TSE_ENDPOINTS.items() ] # CONGRESSO legislatura = extract_legislatura(date) deputados_f = extract_deputados.submit(legislatura) frentes_f = extract_frentes.submit(legislatura) # Reunindo os resultados tse_paths = [f.result() for f in tse_fs] deputados_path = deputados_f.result() frentes_path = frentes_f.result() logger.info(f"Arquivos TSE: {len(tse_paths)}") logger.info(f"Deputados -> {deputados_path}") logger.info(f"Frentes -> {frentes_path}") return { "tse": tse_paths, "congresso_deputados": deputados_path, "congresso_frentes": frentes_path } if name == "__main__": pipeline.serve( # type: ignore name="deploy-1" )
m
✅ thought for 237.0 seconds
Got it — you’re running a synchronous flow with a ThreadPoolTaskRunner and using .submit()/result() for concurrency. To keep that pattern, define your async-based task as a synchronous Prefect task that bridges the coroutine. That way your flow stays sync and your call sites don’t change. Here’s the task following your style:
Copy code
from pathlib import Path
from typing import cast
from prefect import task, get_run_logger
from prefect.utilities.asyncutils import run_coro_as_sync

from <http://utils.io|utils.io> import fetch_json_many_async, save_ndjson
from config.loader import load_config

APP_SETTINGS = load_config()

def frentes_membros_urls(frentes_ids: list[str]) -> list[str]:
    return [f"{APP_SETTINGS.CAMARA.REST_BASE_URL}frentes/{id}/membros" for id in frentes_ids]

@task(
    retries=APP_SETTINGS.CAMARA.RETRIES,
    retry_delay_seconds=APP_SETTINGS.CAMARA.RETRY_DELAY,
    timeout_seconds=APP_SETTINGS.CAMARA.TIMEOUT,
)
def extract_frentes_membros(frentes_ids: list[str], out_dir: str | Path = "data/camara") -> str:
    logger = get_run_logger()
    urls = frentes_membros_urls(frentes_ids)
    <http://logger.info|logger.info>(f"Congresso: buscando Membros de {len(urls)} Frentes")

    # Bridge the async coroutine into this sync task
    jsons = run_coro_as_sync(
        fetch_json_many_async(
            urls,
            concurrency=APP_SETTINGS.CAMARA.CONCURRENCY,
            timeout=APP_SETTINGS.CAMARA.TIMEOUT,
            follow_pagination=True,
        )
    )
    dest = Path(out_dir)
    return save_ndjson(cast(list[dict], jsons), dest)
And here’s how you’d call it from your existing pipeline (unchanged call pattern):
Copy code
# ... your imports and flow definition

@flow(
    task_runner=ThreadPoolTaskRunner(max_workers=APP_SETTINGS.FLOW.MAX_RUNNERS),  # type: ignore
    log_prints=True,
)
def pipeline(
    date: date = datetime.now().date(),
    refresh_cache: bool = False,
):
    logger = get_run_logger()
    <http://logger.info|logger.info>("Iniciando pipeline")

    # TSE in parallel
    tse_fs = [
        # NOTE: pass function parameters to .submit; use with_options() only for task OPTIONS
        extract_tse.submit(name, url, refresh_cache=refresh_cache)
        for name, url in TSE_ENDPOINTS.items()
    ]

    # CONGRESSO
    legislatura = extract_legislatura(date)
    deputados_f = extract_deputados.submit(legislatura)
    frentes_f = extract_frentes.submit(legislatura)

    # If you have frentes_ids available, submit the async-backed task as usual
    # frentes_ids = ...  # derive this from your data
    # frentes_membros_f = extract_frentes_membros.submit(frentes_ids)

    # Gather results
    tse_paths = [f.result() for f in tse_fs]
    deputados_path = deputados_f.result()
    frentes_path = frentes_f.result()
    # frentes_membros_path = frentes_membros_f.result()

    <http://logger.info|logger.info>(f"Arquivos TSE: {len(tse_paths)}")
    <http://logger.info|logger.info>(f"Deputados -> {deputados_path}")
    <http://logger.info|logger.info>(f"Frentes -> {frentes_path}")
    # <http://logger.info|logger.info>(f"Frentes Membros -> {frentes_membros_path}")

    return {
        "tse": tse_paths,
        "congresso_deputados": deputados_path,
        "congresso_frentes": frentes_path,
        # "congresso_frentes_membros": frentes_membros_path,
    }
Why this works: - The task remains synchronous (so your sync flow + .submit/.result pattern is unchanged). - The async I/O is handled inside the task by
run_coro_as_sync
, which safely runs the coroutine. Two small tips: - Use
.submit(..., refresh_cache=refresh_cache)
to pass function parameters.
with_options()
is for task options (name, retries, tags, timeout, etc.), not function args. - If you decide to convert the flow to async later, you can also make the task async and
await
internally; then call it with
.submit()
or
await task(...)
from the async flow. Docs: - Running work concurrently: Run work concurrently
- Async utilities (run_coro_as_sync): Async utilities API - Tasks concept: Tasks concept
g
@Marvin
@Marvin legislatura = extract_legislatura(date) deputados_f = extract_deputados.submit(legislatura) frentes_f = extract_frentes.submit(legislatura) frentes_membros_f = extract_frentes_membros(frentes_f) # Reunindo os resultados tse_paths = [f.result() for f in tse_fs] deputados_path = deputados_f.result() frentes_path = frentes_f.result() This way i have to repeat a lot of code. Is there a cleaner way?
m
✅ thought for 82.3 seconds
Yes — use resolve_futures_to_results to resolve many futures at once and keep your code DRY. It preserves the input structure (lists, dicts, tuples), so you can build a single object and resolve it in one call. Example applied to your flow:
Copy code
from prefect import flow, get_run_logger
from prefect.task_runners import ThreadPoolTaskRunner
from prefect.futures import resolve_futures_to_results

@flow(
    task_runner=ThreadPoolTaskRunner(max_workers=APP_SETTINGS.FLOW.MAX_RUNNERS),  # type: ignore
    log_prints=True,
)
def pipeline(date: date = datetime.now().date(), refresh_cache: bool = False):
    logger = get_run_logger()
    <http://logger.info|logger.info>("Iniciando pipeline")

    # TSE in parallel
    tse_fs = [
        extract_tse.submit(name, url, refresh_cache=refresh_cache)
        for name, url in TSE_ENDPOINTS.items()
    ]

    # CONGRESSO
    legislatura = extract_legislatura(date)
    deputados_f = extract_deputados.submit(legislatura)
    frentes_f = extract_frentes.submit(legislatura)

    # If your task accepts the resolved frentes output, you can pass the future;
    # Prefect will resolve it as an upstream dependency
    frentes_membros_f = extract_frentes_membros.submit(frentes_f)

    # Resolve everything at once, preserving structure
    results = resolve_futures_to_results({
        "tse": tse_fs,
        "congresso_deputados": deputados_f,
        "congresso_frentes": frentes_f,
        "congresso_frentes_membros": frentes_membros_f,
    })

    <http://logger.info|logger.info>(f"Arquivos TSE: {len(results['tse'])}")
    <http://logger.info|logger.info>(f"Deputados -> {results['congresso_deputados']}")
    <http://logger.info|logger.info>(f"Frentes -> {results['congresso_frentes']}")
    <http://logger.info|logger.info>(f"Frentes Membros -> {results['congresso_frentes_membros']}")

    return results
Notes: - Prefer
.submit(...)
for tasks you want to run concurrently. Passing a future as an argument is fine — Prefect wires the dependency and resolves it before the downstream task runs. -
resolve_futures_to_results
works with nested structures, so you can add more futures without growing boilerplate. Refs: - Futures helpers: Run work concurrently - API: prefect.futures
g
frentes_membros_f = extract_frentes_membros.submit(frentes_f) Argument of type "PrefectFuture[list[str]]" cannot be assigned to parameter "frentes_ids" of type "list[str]" in function "submit" "PrefectFuture[list[str]]" is not assignable to "list[str]"Pylance*reportArgumentType* (variable) frentes_f: PrefectFuture[list[str]]
@Marvin frentes_membros_f = extract_frentes_membros.submit(frentes_f) Argument of type "PrefectFuture[list[str]]" cannot be assigned to parameter "frentes_ids" of type "list[str]" in function "submit" "PrefectFuture[list[str]]" is not assignable to "list[str]"Pylance*reportArgumentType* (variable) frentes_f: PrefectFuture[list[str]]
m
✅ thought for 44.2 seconds
You’re doing the right thing by passing the upstream future; Prefect will resolve it at runtime. The error is just the static type checker (PyLance) not knowing that Prefect accepts futures as inputs. Three clean fixes: 1) Relax the task’s type annotation to accept a PrefectFuture
Copy code
from pathlib import Path
from typing import cast
from prefect import task, get_run_logger
from prefect.futures import PrefectFuture

@task(...)
def extract_frentes_membros(
    frentes_ids: list[str] | PrefectFuture[list[str]],
    out_dir: str | Path = "data/camara",
) -> str:
    ...
Then this is type-checker happy:
Copy code
frentes_membros_f = extract_frentes_membros.submit(frentes_f)
2) Cast at the call site (minimal change)
Copy code
from typing import Any, cast

frentes_membros_f = extract_frentes_membros.submit(cast(Any, frentes_f))
3) Ignore the warning inline (PyLance/Pyright)
Copy code
frentes_membros_f = extract_frentes_membros.submit(frentes_f)  # pyright: ignore[reportArgumentType]
And to reduce the repeated
.result()
calls, resolve everything at once with structure-preserving helpers:
Copy code
from prefect.futures import resolve_futures_to_results

results = resolve_futures_to_results({
    "tse": tse_fs,
    "congresso_deputados": deputados_f,
    "congresso_frentes": frentes_f,
    "congresso_frentes_membros": frentes_membros_f,
})
References: - Run work concurrently - prefect.futures API - Tasks concept
g
@Marvin Explain to me again: from prefect import flow, get_run_logger, serve from prefect.task_runners import ThreadPoolTaskRunner from datetime import date, datetime from tasks.tse import TSE_ENDPOINTS, extract_tse from tasks.camara.legislatura import extract_legislatura from tasks.camara.deputados import extract_deputados from tasks.camara.frentes import extract_frentes from tasks.camara.frentes_membros import extract_frentes_membros from config.loader import load_config APP_SETTINGS = load_config() # IMPORTAR TASKS TSE, CONGRESSO, SENADO ETC... @flow( task_runner=ThreadPoolTaskRunner(max_workers=APP_SETTINGS.FLOW.MAX_RUNNERS), # type: ignore log_prints=True ) def pipeline( date: date = datetime.now().date(), refresh_cache: bool = False ): logger = get_run_logger() logger.info("Iniciando pipeline") # TSE: ~30 endpoints em paralelo tse_fs = [ extract_tse .with_options(refresh_cache=refresh_cache) .submit(name, url) for name, url in TSE_ENDPOINTS.items() ] # CONGRESSO legislatura = extract_legislatura(date) deputados_f = extract_deputados.submit(legislatura) frentes_f = extract_frentes.submit(legislatura) frentes_membros_f = extract_frentes_membros.submit(frentes_f) results = { "tse": tse_fs, "congresso_deputados": deputados_f, "congresso_frentes": frentes_f, "congresso_frentes_membros": frentes_membros_f, } if name == "__main__": pipeline.serve( # type: ignore name="deploy-1" ) Error on frentes_membros_f = extract_frentes_membros.submit(frentes_f): Argument of type "PrefectFuture[list[str]]" cannot be assigned to parameter "frentes_ids" of type "list[str]" in function "submit" "PrefectFuture[list[str]]" is not assignable to "list[str]"Pylance*reportArgumentType* (variable) frentes_f: Any from pathlib import Path from prefect import task, get_run_logger from prefect.futures import PrefectFuture from typing import cast from utils.io import fetch_json_many_async, save_ndjson from config.loader import load_config APP_SETTINGS = load_config() def frentes_membros_urls(frentes_ids: list[str]) -> list[str]: return [f"{APP_SETTINGS.CAMARA.REST_BASE_URL}frentes/{id}/membros" for id in frentes_ids] @task( retries=APP_SETTINGS.CAMARA.RETRIES, retry_delay_seconds=APP_SETTINGS.CAMARA.RETRY_DELAY, timeout_seconds=APP_SETTINGS.CAMARA.TIMEOUT ) def extract_frentes_membros(frentes_ids: list[str], out_dir: str | Path = "data/camara") -> str: logger = get_run_logger() urls = frentes_membros_urls(frentes_ids) logger.info(f"Congresso: buscando Membros de {len(urls)} Frentes") jsons = fetch_json_many_async( urls, concurrency=APP_SETTINGS.CAMARA.CONCURRENCY, timeout=APP_SETTINGS.CAMARA.TIMEOUT, follow_pagination=True ) dest = Path(out_dir) return save_ndjson(cast(list[dict], jsons), dest)
m
✅ thought for 37.0 seconds
Thanks for the full snippet — here’s what’s going on and how to fix it cleanly. What the PyLance error means - Prefect lets you pass an upstream
PrefectFuture
as an argument to another task. At runtime, Prefect resolves the future before running the downstream task — so your call
extract_frentes_membros.submit(frentes_f)
is functionally correct. - The error you see is just static type checking (PyLance) complaining because your task parameter is annotated as
list[str]
while you’re passing a
PrefectFuture[list[str]]
. Three ways to quiet the type checker Pick one — the behavior at runtime is the same. 1) Allow the task to accept the future type in its signature
Copy code
from prefect.futures import PrefectFuture

@task(...)
def extract_frentes_membros(
    frentes_ids: list[str] | PrefectFuture[list[str]],
    out_dir: str | Path = "data/camara",
) -> str:
    ...
2) Cast at the call site (keeps task signature “pure”)
Copy code
from typing import Any, cast

frentes_membros_f = extract_frentes_membros.submit(cast(Any, frentes_f))
3) Ignore the warning inline
Copy code
frentes_membros_f = extract_frentes_membros.submit(frentes_f)  # pyright: ignore[reportArgumentType]
Important fix: your async call inside the task
fetch_json_many_async
is async; in a synchronous task you must bridge it. Use `run_coro_as_sync`:
Copy code
from pathlib import Path
from typing import cast
from prefect import task, get_run_logger
from prefect.utilities.asyncutils import run_coro_as_sync

from <http://utils.io|utils.io> import fetch_json_many_async, save_ndjson
from config.loader import load_config

APP_SETTINGS = load_config()

def frentes_membros_urls(frentes_ids: list[str]) -> list[str]:
    return [f"{APP_SETTINGS.CAMARA.REST_BASE_URL}frentes/{id}/membros" for id in frentes_ids]

@task(
    retries=APP_SETTINGS.CAMARA.RETRIES,
    retry_delay_seconds=APP_SETTINGS.CAMARA.RETRY_DELAY,
    timeout_seconds=APP_SETTINGS.CAMARA.TIMEOUT,
)
def extract_frentes_membros(frentes_ids: list[str], out_dir: str | Path = "data/camara") -> str:
    logger = get_run_logger()
    urls = frentes_membros_urls(frentes_ids)
    <http://logger.info|logger.info>(f"Congresso: buscando Membros de {len(urls)} Frentes")

    jsons = run_coro_as_sync(
        fetch_json_many_async(
            urls,
            concurrency=APP_SETTINGS.CAMARA.CONCURRENCY,
            timeout=APP_SETTINGS.CAMARA.TIMEOUT,
            follow_pagination=True,
        )
    )
    dest = Path(out_dir)
    return save_ndjson(cast(list[dict], jsons), dest)
Clean up your flow call sites - Don’t use
with_options
to pass function arguments. Use `.submit(..., refresh_cache=refresh_cache)`;
with_options
is for task options (name, retries, tags, etc.), not function args. - To avoid repetitive
.result()
calls, resolve everything at once and keep your code DRY.
Copy code
from prefect import flow, get_run_logger
from prefect.task_runners import ThreadPoolTaskRunner
from prefect.futures import resolve_futures_to_results

@flow(
    task_runner=ThreadPoolTaskRunner(max_workers=APP_SETTINGS.FLOW.MAX_RUNNERS),  # type: ignore
    log_prints=True,
)
def pipeline(date: date = datetime.now().date(), refresh_cache: bool = False):
    logger = get_run_logger()
    <http://logger.info|logger.info>("Iniciando pipeline")

    # TSE in parallel
    tse_fs = [
        extract_tse.submit(name, url, refresh_cache=refresh_cache)
        for name, url in TSE_ENDPOINTS.items()
    ]

    # CONGRESSO
    legislatura = extract_legislatura(date)
    deputados_f = extract_deputados.submit(legislatura)
    frentes_f = extract_frentes.submit(legislatura)

    # Passing a future to another task is fine; Prefect resolves it
    frentes_membros_f = extract_frentes_membros.submit(frentes_f)

    # Resolve everything at once (preserves structure)
    results = resolve_futures_to_results({
        "tse": tse_fs,
        "congresso_deputados": deputados_f,
        "congresso_frentes": frentes_f,
        "congresso_frentes_membros": frentes_membros_f,
    })

    return results
References - Run work concurrently and resolve futures: Run work concurrently - Async utils (
run_coro_as_sync
): Async utilities API - Tasks concept: Tasks concept
g
1 - What if i prefer to set async await in the extract_frentes_membros? Im more familiar with this method. How should be? 2 - when i set: def frentes_membros_urls(frentes_ids: list[str] | PrefectFuture[list[str]]) -> list[str]: return [f"{APP_SETTINGS.CAMARA.REST_BASE_URL}frentes/{id}/membros" for id in frentes_ids] it says: "PrefectFuture[list[str]]" is not iterable "__iter__" method not definedPylance*reportGeneralTypeIssues* (parameter) frentes_ids: list[str] | PrefectFuture[list[str]] 3 - if i do: tse_fs = [ extract_tse .submit(name, url, refresh_cache=refresh_cache) for name, url in TSE_ENDPOINTS.items() ] it gives an error: No overloads for "submit" match the provided arguments Argument types: (str, str, bool)PylancereportCallIssue
@Marvin 1 - What if i prefer to set async await in the extract_frentes_membros? Im more familiar with this method. How should be? 2 - when i set: def frentes_membros_urls(frentes_ids: list[str] | PrefectFuture[list[str]]) -> list[str]: return [f"{APP_SETTINGS.CAMARA.REST_BASE_URL}frentes/{id}/membros" for id in frentes_ids] it says: "PrefectFuture[list[str]]" is not iterable "__iter__" method not definedPylance*reportGeneralTypeIssues* (parameter) frentes_ids: list[str] | PrefectFuture[list[str]] 3 - if i do: tse_fs = [ extract_tse .submit(name, url, refresh_cache=refresh_cache) for name, url in TSE_ENDPOINTS.items() ] it gives an error: No overloads for "submit" match the provided arguments Argument types: (str, str, bool)PylancereportCallIssue
m
✅ thought for 112.6 seconds
Great questions — tackling each: 1) If you prefer async/await in extract_frentes_membros Make the task async and await your coroutine inside. You can keep your flow synchronous and still call the async task with .submit() for concurrency, or make the flow async and use await directly — both work. Async task
Copy code
from pathlib import Path
from typing import cast
from prefect import task, get_run_logger

from <http://utils.io|utils.io> import fetch_json_many_async, save_ndjson
from config.loader import load_config

APP_SETTINGS = load_config()

def frentes_membros_urls(frentes_ids: list[str]) -> list[str]:
    return [f"{APP_SETTINGS.CAMARA.REST_BASE_URL}frentes/{id}/membros" for id in frentes_ids]

@task(
    retries=APP_SETTINGS.CAMARA.RETRIES,
    retry_delay_seconds=APP_SETTINGS.CAMARA.RETRY_DELAY,
    timeout_seconds=APP_SETTINGS.CAMARA.TIMEOUT,
)
async def extract_frentes_membros(frentes_ids: list[str], out_dir: str | Path = "data/camara") -> str:
    logger = get_run_logger()
    urls = frentes_membros_urls(frentes_ids)
    <http://logger.info|logger.info>(f"Congresso: buscando Membros de {len(urls)} Frentes")

    jsons = await fetch_json_many_async(
        urls,
        concurrency=APP_SETTINGS.CAMARA.CONCURRENCY,
        timeout=APP_SETTINGS.CAMARA.TIMEOUT,
        follow_pagination=True,
    )
    dest = Path(out_dir)
    return save_ndjson(cast(list[dict], jsons), dest)
How to call it: - Keep your current sync flow and use concurrency: -
frentes_membros_f = extract_frentes_membros.submit(frentes_f)
# pass the upstream future; Prefect wires dependencies and resolves it at runtime - Later, get the value with
.result()
or use
resolve_futures_to_results(...)
- Or convert the flow to async and await directly: -
frentes_ids = await extract_frentes(legislatura)
then
result = await extract_frentes_membros(frentes_ids)
- Or still use
.submit()
for concurrency and collect later with
.result()
Docs: - Add logging: Add logging - Concurrency patterns: Run work concurrently 2) "PrefectFuture[list[str]] is not iterable" in frentes_membros_urls Don’t add PrefectFuture to the helper’s signature. Prefect resolves upstream futures before calling your task function, so inside the task you will receive a concrete list[str]. Keep your helper typed strictly:
Copy code
def frentes_membros_urls(frentes_ids: list[str]) -> list[str]:
    return [f"{APP_SETTINGS.CAMARA.REST_BASE_URL}frentes/{id}/membros" for id in frentes_ids]
If PyLance complains at the flow call site when you pass a future to the task, use one of these to quiet the type checker: - Cast at call site:
Copy code
from typing import Any, cast
  frentes_membros_f = extract_frentes_membros.submit(cast(Any, frentes_f))
- Or ignore inline:
Copy code
frentes_membros_f = extract_frentes_membros.submit(frentes_f)  # pyright: ignore[reportArgumentType]
3) "No overloads for submit match" when calling extract_tse.submit(name, url, refresh_cache=refresh_cache) This is a PyLance typing limitation with Prefect’s Task/submit wrappers, not a runtime problem. Two options: - Cast the task to Any at the call site (simple and common with static checkers):
Copy code
from typing import Any, cast

  tse_fs = [
      cast(Any, extract_tse).submit(name, url, refresh_cache=refresh_cache)
      for name, url in TSE_ENDPOINTS.items()
  ]
- Or ensure your task’s function signature matches exactly what you’re passing (names and types). If it does, the cast above is still the pragmatic fix for PyLance. Bonus: resolving futures without repetitive .result()
Copy code
from prefect.futures import resolve_futures_to_results

results = resolve_futures_to_results({
    "tse": tse_fs,
    "congresso_deputados": deputados_f,
    "congresso_frentes": frentes_f,
    "congresso_frentes_membros": frentes_membros_f,
})
References:
- Concurrency and futures helpers: Run work concurrently - Async utilities: Async utilities API - Tasks concept: Tasks concept
g
@Marvin async def fetch_json_many_async( urls: list[str], out_dir: str | Path | None = None, concurrency: int = 10, timeout: float = 30.0, follow_pagination: bool = True ) -> list[str] | list[dict]: """ - Se out_dir for fornecido, salva cada JSON em um arquivo e retorna a lista de caminhos - Caso contrårio, retorna a lista de dicionårios em memória """ sem = asyncio.Semaphore(concurrency) limits = httpx.Limits(max_connections=max(concurrency, 10)) timeout_cfg = httpx.Timeout(timeout) ensure_dir(out_dir) if out_dir else None processed_urls = set() # Evita processar a mesma URL duas vezes results = [] async def one(u: str): if u in processed_urls: return [] processed_urls.add(u) async with sem: async with httpx.AsyncClient(limits=limits, timeout=timeout_cfg) as client: r = await client.get(u) r.raise_for_status() data = r.json() # Salvar ou retornar o resultado atual if out_dir: # Nome do arquivo determinado pelo Hash da URL name = hashlib.sha1(u.encode()).hexdigest() + ".json" path = Path(out_dir) / name with open(path, "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False) current_result = str(path) else: current_result = data # Verifica se deve serguir a paginação additional_results = [] if follow_pagination and "links" in data: links = {link["rel"]: link["href"] for link in data["links"]} if "self" in links and "last" in links: if links["self"] != links["last"] and "next" in links: next_url = links["next"] additional_results = await one(next_url) # Retorna resultado atual + resultados adicionais da paginação if isinstance(additional_results, list): return [current_result] + additional_results else: return [current_result] tasks = [one(u) for u in urls] *nested_results = await asyncio.gather(tasks) # Achata a lista de resultados for item in nested_results: if isinstance(item, list): results.extend(item) else: results.append(item) return results in this function (thats runs on tasks) i want to print a log for each url thats downloading
m
✅ thought for 133.4 seconds
Two easy ways to log one line per URL while this function runs inside a Prefect task: Option A — Use Prefect’s run logger (structured logs in the task run) - Pass a logger into the function (recommended), or lazily get it with get_run_logger() when running under a task. Fall back to print if called outside Prefect.
Copy code
from typing import Any
from prefect import get_run_logger
from prefect.exceptions import MissingContextError

def _get_prefect_logger_or_none() -> Any | None:
    try:
        return get_run_logger()
    except MissingContextError:
        return None

async def fetch_json_many_async(
    urls: list[str],
    out_dir: str | Path | None = None,
    concurrency: int = 10,
    timeout: float = 30.0,
    follow_pagination: bool = True,
    logger: Any | None = None,   # <- NEW
) -> list[str] | list[dict]:
    """
    - Se out_dir for fornecido, salva cada JSON em um arquivo e retorna a lista de caminhos
    - Caso contrĂĄrio, retorna a lista de dicionĂĄrios em memĂłria
    """
    logger = logger or _get_prefect_logger_or_none()

    def log(msg: str):
        if logger:
            <http://logger.info|logger.info>(msg)
        else:
            print(msg)

    sem = asyncio.Semaphore(concurrency)
    limits = httpx.Limits(max_connections=max(concurrency, 10))
    timeout_cfg = httpx.Timeout(timeout)
    ensure_dir(out_dir) if out_dir else None

    processed_urls = set()
    results: list[str] | list[dict] = []

    async def one(u: str):
        if u in processed_urls:
            log(f"Skipping duplicate URL: {u}")
            return []
        processed_urls.add(u)

        log(f"Downloading {u}")
        async with sem:
            async with httpx.AsyncClient(limits=limits, timeout=timeout_cfg) as client:
                r = await client.get(u)
                r.raise_for_status()
                data = r.json()

        if out_dir:
            name = hashlib.sha1(u.encode()).hexdigest() + ".json"
            path = Path(out_dir) / name
            with open(path, "w", encoding="utf-8") as f:
                json.dump(data, f, ensure_ascii=False)
            current_result = str(path)
            log(f"Downloaded {u} -> {current_result}")
        else:
            current_result = data
            log(f"Downloaded {u}")

        additional_results = []
        if follow_pagination and isinstance(data, dict) and "links" in data:
            links = {link["rel"]: link["href"] for link in data.get("links", []) if isinstance(link, dict)}
            if links.get("self") and links.get("last") and links["self"] != links["last"] and "next" in links:
                next_url = links["next"]
                log(f"Following pagination: {u} -> next {next_url}")
                additional_results = await one(next_url)

        if isinstance(additional_results, list):
            return [current_result] + additional_results
        else:
            return [current_result]

    tasks = [one(u) for u in urls]
    nested_results = await asyncio.gather(*tasks)

    for item in nested_results:
        if isinstance(item, list):
            results.extend(item)
        else:
            results.append(item)

    log(f"Finished: {len(results)} items")
    return results
Usage from your task:
Copy code
@task(log_prints=False, ...)  # log_prints not needed when using logger
def my_task(...):
    logger = get_run_logger()
    jsons = run_coro_as_sync(fetch_json_many_async(urls, out_dir=None, logger=logger))
Option B — Just use print and capture via log_prints - If you prefer prints, turn on print capture: - Globally in prefect.toml:
Copy code
[logging]
    level = "INFO"
    log_prints = true
- Or per task:
@task(log_prints=True)
- Then add print lines in the same spots:
Copy code
print(f"Downloading {u}")
print(f"Downloaded {u} -> {current_result}")
print(f"Following pagination: {u} -> next {next_url}")
print(f"Finished: {len(results)} items")
Notes:
- Using get_run_logger provides Prefect-aware logs with levels and run/task context. - If this function might also run outside Prefect, the try/except around get_run_logger prevents errors and falls back to print. - Because downloads run concurrently, logs will interleave; including the URL in each line makes them easy to follow. Docs: - Add logging: Add logging - Advanced logging customization: Advanced logging customization
g
@Marvin what is an link artifact?
m
✅ thought for 182.5 seconds
A link artifact in Prefect 3.x is a small, persisted UI record that displays a clickable hyperlink associated with a flow or task run. It’s great for pointing from a run to external resources like dashboards, logs, or reports. Where it shows up - Without a key: in the run’s Artifacts tab (for that specific flow/task run). - With a key: also on the global Artifacts page in the Prefect UI, where subsequent creations with the same key appear as versions. How to create one - Use
prefect.artifacts.create_link_artifact
. It stores the link as markdown so it renders as a clickable link in the UI. The function returns the artifact’s UUID. Example
Copy code
from prefect import flow
from prefect.artifacts import create_link_artifact

@flow
def demo():
    create_link_artifact(
        link="<https://dash.mycompany.com/reports/123>",
        link_text="Open KPI dashboard",    # optional; defaults to the URL if omitted
        key="kpi-dashboard-link",          # optional; lowercase letters, numbers, dashes
        description="Daily KPI dashboard for 2025-10-25",
    )

demo()
How it differs from other artifact types - LinkArtifact: renders a single clickable link. - MarkdownArtifact: arbitrary markdown content. - TableArtifact: tabular data that renders as a table. - ImageArtifact: displays an image from a public URL. - ProgressArtifact: shows progress (0–100) and is the only type you update in place. Docs and API - Concepts: Artifacts concept - How-to guide: Create and use artifacts - API reference: prefect.artifacts API If you share where you want to link out to (e.g., S3 objects, BI dashboards, Snowflake worksheets), I can tailor an example.
g
@Marvin from pathlib import Path from prefect import task, get_run_logger from prefect.artifacts import create_progress_artifact from typing import cast from utils.io import fetch_json_many_async, save_ndjson from config.loader import load_config APP_SETTINGS = load_config() def frentes_membros_urls(frentes_ids: list[str]) -> list[str]: return [f"{APP_SETTINGS.CAMARA.REST_BASE_URL}frentes/{id}/membros" for id in frentes_ids] @task( retries=APP_SETTINGS.CAMARA.RETRIES, retry_delay_seconds=APP_SETTINGS.CAMARA.RETRY_DELAY, timeout_seconds=APP_SETTINGS.CAMARA.TIMEOUT ) async def extract_frentes_membros(frentes_ids: list[str], out_dir: str | Path = "data/camara") -> str: logger = get_run_logger() progress_artifact = create_progress_artifact( progress=0.0, description="Download de membros de frentes da Câmara" ) urls = frentes_membros_urls(frentes_ids) logger.info(f"Congresso: buscando Membros de {len(urls)} Frentes") jsons = await fetch_json_many_async( urls=urls, concurrency=APP_SETTINGS.CAMARA.CONCURRENCY, timeout=APP_SETTINGS.CAMARA.TIMEOUT, follow_pagination=True, progress_artifact_id=progress_artifact ) dest = Path(out_dir) / "frentes_membros.ndjson" return save_ndjson(cast(list[dict], jsons), dest) # Armazena em memória ou grava em disco uma lista de JSONs async def fetch_json_many_async( urls: list[str], out_dir: str | Path | None = None, concurrency: int = 10, timeout: float = 30.0, follow_pagination: bool = True, logger: Any | None = None, progress_artifact_id: Any | None = None, ) -> list[str] | list[dict]: """ - Se out_dir for fornecido, salva cada JSON em um arquivo e retorna a lista de caminhos - Caso contrårio, retorna a lista de dicionårios em memória """ logger = logger or _get_prefect_logger_or_none() def log(msg: str): if logger: logger.info(msg) else: print(msg) sem = asyncio.Semaphore(concurrency) limits = httpx.Limits(max_connections=max(concurrency, 10)) timeout_cfg = httpx.Timeout(timeout) ensure_dir(out_dir) if out_dir else None processed_urls = set() # Evita processar a mesma URL duas vezes results = [] downloaded_urls = 0 update_lock = asyncio.Lock() # Evita race conditions ao atualizar o progresso de forma assíncrona async def one(u: str): nonlocal downloaded_urls if u in processed_urls: return [] processed_urls.add(u) async with sem: async with httpx.AsyncClient(limits=limits, timeout=timeout_cfg) as client: log(f"Fazendo download da URL: {u}") r = await client.get(u) r.raise_for_status() data = r.json() # Salvar ou retornar o resultado atual if out_dir: # Nome do arquivo determinado pelo Hash da URL name = hashlib.sha1(u.encode()).hexdigest() + ".json" path = Path(out_dir) / name with open(path, "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False) current_result = str(path) else: current_result = data if progress_artifact_id and len(urls) > 0: async with update_lock: downloaded_urls += 1 update_progress_artifact( artifact_id=progress_artifact_id, *progress=(downloaded_urls / len(urls)) * 100* ) # Verifica se deve serguir a paginação additional_results = [] if follow_pagination and "links" in data: links = {link["rel"]: link["href"] for link in data["links"]} if "self" in links and "last" in links: if links["self"] != links["last"] and "next" in links: next_url = links["next"] additional_results = await one(next_url) # Retorna resultado atual + resultados adicionais da paginação if isinstance(additional_results, list): return [current_result] + additional_results else: return [current_result] tasks = [one(u) for u in urls] *nested_results = await asyncio.gather(tasks) # Achata a lista de resultados for item in nested_results: if isinstance(item, list): results.extend(item) else: results.append(item) return results 1 - Why theres no progress indicator in the UI?
m
Your message was too long, here's your message at the allowed limit: from pathlib import Path from prefect import task get_run_logger from prefect artifacts import create_progress_artifact from typing import cast from <http //utils io|utils io> import fetch_json_many_async save_ndjson from config loader import load_config APP_SETTINGS = load_config() *def frentes_membros_urls(frentes_ids list[str]) -> list[str] * return [f {APP_SETTINGS CAMARA REST_BASE_URL}frentes/{id}/membros for id in frentes_ids] @task( *retries=APP_SETTINGS CAMARA RETRIES * *retry_delay_seconds=APP_SETTINGS CAMARA RETRY_DELAY * timeout_seconds=APP_SETTINGS CAMARA TIMEOUT ) *async def extract_frentes_membros(frentes_ids list[str] out_dir str | Path = data/camara ) -> str * logger = get_run_logger() progress_artifact = create_progress_artifact( *progress=0 0 * *description= Download de membros de frentes da Câmara * ) urls = frentes_membros_urls(frentes_ids) <http //logger info|logger info>(f Congresso buscando Membros de {len(urls)} Frentes ) jsons = await fetch_json_many_async( *urls=urls * *concurrency=APP_SETTINGS CAMARA CONCURRENCY * *timeout=APP_SETTINGS CAMARA TIMEOUT * *follow_pagination=True * progress_artifact_id=progress_artifact ) *dest = Path(out_dir) / frentes_membros ndjson * return save_ndjson(cast(list[dict] jsons) dest) # Armazena em memória ou grava em disco uma lista de JSONs async def fetch_json_many_async( *urls list[str] * *out_dir str | Path | None = None * *concurrency int = 10 * *timeout float = 30 0 * *follow_pagination bool = True * *logger Any | None = None * *progress_artifact_id Any | None = None * *) -> list[str] | list[dict] * * * - Se out_dir for fornecido salva cada JSON em um arquivo e retorna a lista de caminhos - Caso contrårio retorna a lista de dicionårios em memória * * logger = logger or _get_prefect_logger_or_none() *def log(msg str) * *if logger * <http //logger info|logger info>(msg) *else * print(msg) sem = asyncio Semaphore(concurrency) limits = httpx Limits(max_connections=max(concurrency 10)) timeout_cfg = httpx Timeout(timeout) ensure_dir(out_dir) if out_dir else None processed_urls = set() # Evita processar a mesma URL duas vezes results = [] downloaded_urls = 0 update_lock = asyncio Lock() # Evita race conditions ao atualizar o progresso de forma assíncrona *async def one(u str) * nonlocal downloaded_urls *if u in processed_urls * return [] processed_urls add(u) *async with sem * *async with httpx AsyncClient(limits=limits timeout=timeout_cfg) as client * log(f Fazendo download da URL {u} ) r = await client get(u) r raise_for_status() data = r json() # Salvar ou retornar o resultado atual *if out_dir * # Nome do arquivo determinado pelo Hash da URL *name = hashlib sha1(u encode()) hexdigest() + json * path = Path(out_dir) / name *with open(path w encoding= utf-8 ) as f * json dump(data f ensure_ascii=False) current_result = str(path) *else * current_result = data *if progress_artifact_id and len(urls) > 0 * *async with update_lock * downloaded_urls += 1 update_progress_artifact( *artifact_id=progress_artifact_id * *progress=(downloaded_urls / len(urls)) * 100* ) # Verifica se deve serguir a paginação additional_results = [] *if follow_pagination and links in data * links = {link[ rel ] link[ href ] for link in data[ links ]} *if self in links and last in links * *if links[ self ] != links[ last ] and next in links * next_url = links[ next ] additional_results = await one(next_url) # Retorna resultado atual + resultados adicionais da paginação *if isinstance(additional_results list) * *return
g
@Marvin jsons = await fetch_json_many_async( urls=urls, concurrency=APP_SETTINGS.CAMARA.CONCURRENCY, timeout=APP_SETTINGS.CAMARA.TIMEOUT, follow_pagination=True, progress_artifact_id=progress_artifact ) async def fetch_json_many_async( urls: list[str], out_dir: str | Path | None = None, concurrency: int = 10, timeout: float = 30.0, follow_pagination: bool = True, logger: Any | None = None, progress_artifact_id: Any | None = None, ) -> list[str] | list[dict]: """ - Se out_dir for fornecido, salva cada JSON em um arquivo e retorna a lista de caminhos - Caso contrårio, retorna a lista de dicionårios em memória """ logger = logger or _get_prefect_logger_or_none() def log(msg: str): if logger: logger.info(msg) else: print(msg) sem = asyncio.Semaphore(concurrency) limits = httpx.Limits(max_connections=max(concurrency, 10)) timeout_cfg = httpx.Timeout(timeout) ensure_dir(out_dir) if out_dir else None processed_urls = set() # Evita processar a mesma URL duas vezes results = [] downloaded_urls = 0 update_lock = asyncio.Lock() # Evita race conditions ao atualizar o progresso de forma assíncrona async def one(u: str): nonlocal downloaded_urls if u in processed_urls: return [] processed_urls.add(u) async with sem: async with httpx.AsyncClient(limits=limits, timeout=timeout_cfg) as client: log(f"Fazendo download da URL: {u}") r = await client.get(u) r.raise_for_status() data = r.json() # Salvar ou retornar o resultado atual if out_dir: # Nome do arquivo determinado pelo Hash da URL name = hashlib.sha1(u.encode()).hexdigest() + ".json" path = Path(out_dir) / name with open(path, "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False) current_result = str(path) else: current_result = data if progress_artifact_id and len(urls) > 0: async with update_lock: downloaded_urls += 1 update_progress_artifact( artifact_id=progress_artifact_id, *progress=(downloaded_urls / len(urls)) * 100* ) # Verifica se deve serguir a paginação additional_results = [] if follow_pagination and "links" in data: links = {link["rel"]: link["href"] for link in data["links"]} if "self" in links and "last" in links: if links["self"] != links["last"] and "next" in links: next_url = links["next"] additional_results = await one(next_url) # Retorna resultado atual + resultados adicionais da paginação if isinstance(additional_results, list): return [current_result] + additional_results else: return [current_result] tasks = [one(u) for u in urls] *nested_results = await asyncio.gather(tasks) # Achata a lista de resultados for item in nested_results: if isinstance(item, list): results.extend(item) else: results.append(item) return results Why theres no progress indicator in the UI?
m
✅ thought for 114.0 seconds
Short answer: a progress bar only shows up in the UI if you first create a Progress artifact, update that exact artifact’s ID during a flow/task run (or give it a key if outside a run), and look in the right place in the UI. Common reasons it doesn’t appear - No artifact was created:
progress_artifact_id
is None or not a UUID returned by create_progress_artifact/acreate_progress_artifact, so your
if progress_artifact_id
block never runs. - Created outside a Prefect run with no key: artifacts without a key only show on the associated flow/task run’s Artifacts tab. If there’s no run context, it won’t be visible anywhere. Add a
key
to see it on the global Artifacts page. - Checking the wrong UI page: - Without a key: see Flow Run -> Artifacts tab - With a key: see the global Artifacts page - Wrong type or version mismatch: make sure you’re using a Progress artifact in Prefect 3.x and updating it with the correct function. Known-good pattern
Copy code
from prefect import flow
from prefect.artifacts import acreate_progress_artifact, aupdate_progress_artifact

@flow
async def main(urls):
    # 1) Create the progress artifact and capture its UUID
    progress_id = await acreate_progress_artifact(
        progress=0,
        description="Starting downloads…",
        # Optional: add a key to also see it on the global Artifacts page
        key="camara-download-progress"
    )

    # 2) Pass the UUID to your async helper
    results = await fetch_json_many_async(
        urls=urls,
        concurrency=10,
        timeout=30.0,
        follow_pagination=True,
        progress_artifact_id=progress_id,
    )

    # 3) Final update to 100%
    await aupdate_progress_artifact(
        artifact_id=progress_id,
        progress=100,
        description="Downloads complete"
    )
Notes for your function - Your
if progress_artifact_id and len(urls) > 0:
guard means no updates happen if the ID is falsy. Confirm you’re passing the UUID returned by create/acreate. - In an async function, prefer
aupdate_progress_artifact
to avoid blocking, though using the sync version won’t prevent it from showing up. - If this runs outside a flow/task, set a
key
on creation; otherwise it won’t be visible in the UI. Docs - Concepts: Artifacts concept - How-to: Create and use artifacts - API: prefect.artifacts API If you can share how you set
progress_artifact_id
(the creation snippet) and where you’re looking in the UI, I can pinpoint the exact issue.
g
@Marvin from pathlib import Path from prefect import task, get_run_logger from prefect.artifacts import create_progress_artifact from typing import cast from utils.io import fetch_json_many_async, save_ndjson from config.loader import load_config APP_SETTINGS = load_config() def frentes_membros_urls(frentes_ids: list[str]) -> list[str]: return [f"{APP_SETTINGS.CAMARA.REST_BASE_URL}frentes/{id}/membros" for id in frentes_ids] @task( retries=APP_SETTINGS.CAMARA.RETRIES, retry_delay_seconds=APP_SETTINGS.CAMARA.RETRY_DELAY, timeout_seconds=APP_SETTINGS.CAMARA.TIMEOUT ) async def extract_frentes_membros(frentes_ids: list[str], out_dir: str | Path = "data/camara") -> str: logger = get_run_logger() progress_artifact = create_progress_artifact( progress=0.0, description="Download de membros de frentes da Câmara" ) urls = frentes_membros_urls(frentes_ids) logger.info(f"Congresso: buscando Membros de {len(urls)} Frentes") jsons = await fetch_json_many_async( urls=urls, concurrency=APP_SETTINGS.CAMARA.CONCURRENCY, timeout=APP_SETTINGS.CAMARA.TIMEOUT, follow_pagination=True, progress_artifact_id=progress_artifact ) dest = Path(out_dir) / "frentes_membros.ndjson" return save_ndjson(cast(list[dict], jsons), dest) This is the code that im calling the artifact. Im clicking on runs and on the bar of the fetch_frentes_membros i expand it but theres no indicator
m
✅ thought for 128.8 seconds
Got it — thanks for sharing the code. Two key points that usually explain “no progress indicator” in the UI: 1) Where to look in the UI - Prefect does not render progress artifacts inline on the task row/timeline. Expanding the task row will not show a progress bar. - Instead: - Open the task run details for your
extract_frentes_membros
task, then go to its Artifacts tab. - Or give the artifact a key when creating it, then check the global Artifacts page. 2) Make sure you’re creating and updating the same Progress artifact - Your pattern is right: create a Progress artifact at the start of the task, pass its UUID into your helper, and call update on that UUID. - Since your task and helper are async, I recommend using the async API to avoid blocking and ensure the UI gets timely updates. Suggested changes
Copy code
from prefect import task, get_run_logger
from prefect.artifacts import acreate_progress_artifact, aupdate_progress_artifact

@task(
    retries=APP_SETTINGS.CAMARA.RETRIES,
    retry_delay_seconds=APP_SETTINGS.CAMARA.RETRY_DELAY,
    timeout_seconds=APP_SETTINGS.CAMARA.TIMEOUT,
)
async def extract_frentes_membros(frentes_ids: list[str], out_dir: str | Path = "data/camara") -> str:
    logger = get_run_logger()

    # Create the progress artifact (optionally add a key to also see it on the global Artifacts page)
    progress_artifact = await acreate_progress_artifact(
        progress=0.0,
        description="Download de membros de frentes da Câmara",
        # key="frentes-membros-progress"  # optional
    )
    <http://logger.info|logger.info>(f"Progress artifact id: {progress_artifact}")

    urls = frentes_membros_urls(frentes_ids)
    <http://logger.info|logger.info>(f"Congresso: buscando Membros de {len(urls)} Frentes")

    jsons = await fetch_json_many_async(
        urls=urls,
        concurrency=APP_SETTINGS.CAMARA.CONCURRENCY,
        timeout=APP_SETTINGS.CAMARA.TIMEOUT,
        follow_pagination=True,
        progress_artifact_id=progress_artifact,
    )

    # Final update to 100% is a nice touch
    await aupdate_progress_artifact(
        artifact_id=progress_artifact,
        progress=100.0,
        description="Downloads concluĂ­dos"
    )

    dest = Path(out_dir) / "frentes_membros.ndjson"
    return save_ndjson(cast(list[dict], jsons), dest)
And in your helper, use the async updater:
Copy code
from prefect.artifacts import aupdate_progress_artifact

# ...
if progress_artifact_id and len(urls) > 0:
    async with update_lock:
        downloaded_urls += 1
        await aupdate_progress_artifact(
            artifact_id=progress_artifact_id,
            progress=(downloaded_urls / len(urls)) * 100
        )
Quick checklist - Confirm you’re opening the task run’s details page and checking the Artifacts tab. - Log the artifact UUID right after creation to ensure it’s not None. - Try adding a key to the artifact on creation; then check the global Artifacts page to confirm it exists. - Ensure your
update_progress_artifact
calls are actually executed (e.g.,
urls
length > 0, no early returns). Docs - Concepts: Artifacts concept - How-to: Create and use artifacts - API: prefect.artifacts API If you can share a screenshot of where you’re looking in the UI (task run details vs. expanded row) and your Prefect version, I can confirm precisely what you should see.
g
@Marvin I added the key and async update but seeing the logs of the taks i got: Task run failed with exception: PrefectHTTPStatusError("Client error '404 Not Found' for url 'http://127.0.0.1:4200/api/artifacts/%3Ccoroutine%20object%20acreate_progress_artifact%20at%200x00000227FFBBA440%3E'\nResponse: {'detail': 'Not Found'}\nFor more information check: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/404") - Retry 2/30 will start 5 second(s) from now
m
✅ thought for 17.6 seconds
That 404 tells us the artifact_id you’re sending is a coroutine object, not a UUID. See the URL fragment:
<coroutine object acreate_progress_artifact at ...>
— that happens when
acreate_progress_artifact(...)
wasn’t awaited. Fix - Await the async creation and updates, and pass the UUID to your helper. Correct pattern
Copy code
from uuid import UUID
from prefect.artifacts import acreate_progress_artifact, aupdate_progress_artifact

@task(...)
async def extract_frentes_membros(frentes_ids: list[str], out_dir: str | Path = "data/camara") -> str:
    logger = get_run_logger()

    progress_id = await acreate_progress_artifact(
        progress=0.0,
        description="Download de membros de frentes da Câmara",
        key="frentes-membros-progress"
    )
    <http://logger.info|logger.info>(f"Progress artifact id: {progress_id} (type={type(progress_id)})")
    assert isinstance(progress_id, UUID)

    urls = frentes_membros_urls(frentes_ids)
    <http://logger.info|logger.info>(f"Congresso: buscando Membros de {len(urls)} Frentes")

    jsons = await fetch_json_many_async(
        urls=urls,
        concurrency=APP_SETTINGS.CAMARA.CONCURRENCY,
        timeout=APP_SETTINGS.CAMARA.TIMEOUT,
        follow_pagination=True,
        progress_artifact_id=progress_id,  # <- pass the UUID
    )

    await aupdate_progress_artifact(
        artifact_id=progress_id,
        progress=100.0,
        description="Downloads concluĂ­dos"
    )

    dest = Path(out_dir) / "frentes_membros.ndjson"
    return save_ndjson(cast(list[dict], jsons), dest)
And inside your helper:
Copy code
from prefect.artifacts import aupdate_progress_artifact

# ...
if progress_artifact_id and len(urls) > 0:
    async with update_lock:
        downloaded_urls += 1
        await aupdate_progress_artifact(
            artifact_id=progress_artifact_id,
            progress=(downloaded_urls / len(urls)) * 100
        )
Also, make sure you’re looking at: - Task run details -> Artifacts tab (without a key), or - Global Artifacts page (since you added a key). Docs for reference: - API: prefect.artifacts API - How-to: Create and use artifacts If you still hit 404 after this, paste the exact lines where you create the artifact and log its value/type. That’ll confirm whether it’s a UUID or not.
g
@Marvin I did everything you told me to do, creating an async artifact updater but in the ui the progress circle is stuck visually but when i access the artifact it shows the correct data
m
✅ thought for 199.7 seconds
Thanks for the detail — this symptom matches a UI refresh issue: the progress artifact data is updating correctly in the API/DB, but the circle you’re looking at isn’t re-rendering as updates come in. What’s likely happening - The UI polls the API periodically. Some views don’t live-update the progress circle until the next poll or until you navigate/refresh the page, even though the artifact itself shows the latest value when opened. - If you’re updating very frequently (e.g., per-URL with high concurrency), the UI can appear “stuck” between polls. The artifact detail view will show the correct value because it fetches fresh data when you open it. Quick checks and workarounds - Try a hard refresh on the page (Shift+Reload) or navigate away and back; see if the circle advances. - Throttle updates: only call
aupdate_progress_artifact
on meaningful increments (e.g., every N items or every 1–2s) so the UI has time to poll and show visible changes. - Confirm you’re looking in the task run’s Artifacts tab or the global Artifacts page (since you added a key). - Make sure you’re using the async updater and awaiting it: -
progress_id = await acreate_progress_artifact(...)
-
await aupdate_progress_artifact(artifact_id=progress_id, progress=...)
Minimal repro you can run to observe UI behavior
Copy code
from prefect import flow
from prefect.artifacts import acreate_progress_artifact, aupdate_progress_artifact
import asyncio

@flow
async def progress_ui_test():
    aid = await acreate_progress_artifact(progress=0, key="ui-progress-test", description="UI progress test")
    for i in range(0, 101, 10):
        await aupdate_progress_artifact(artifact_id=aid, progress=i, description=f"{i}%")
        await asyncio.sleep(2)  # allow the UI to poll between updates

progress_ui_test()
- Open the global Artifacts page and watch the artifact with key
ui-progress-test
. If the circle doesn’t move unless you refresh, it confirms the polling/refresh behavior. If it still looks stuck after trying the above - Share these details and I can dig deeper or file an issue: - Prefect version (prefect version) - Prefect Cloud or self-hosted (and UI URL/port — I see 127.0.0.1:4200) - Which exact page/view shows the stuck circle (global Artifacts list, task run Artifacts tab, run summary, etc.) - Browser and version - Whether throttling updates (e.g., every 1–2s) changes the behavior References - Artifacts how-to: Create and use artifacts - API reference: prefect.artifacts API
g
@Marvin Im self hosting Im in the run details where it shows the bars of the tasks. i have added : if progress_artifact_id and len(urls) > 0: async with update_lock: downloaded_urls += 1 # SĂł atualiza a cada 5 urls baixadas, para atualizar o progress bar da UI if downloaded_urls % 5 == 0 or downloaded_urls == len(urls): await aupdate_progress_artifact( artifact_id=progress_artifact_id, *progress=(downloaded_urls / len(urls)) * 100* ) So it would update only after 5 urls downloaded (in this example there are 305 to download). But the progress circle still not updating automatically, without having to refresh. Also i got another question:
m
✅ thought for 39.9 seconds
Thanks for the extra context. Since you’re self‑hosting and watching the run details page (the view with the task bars), what you’re seeing is expected today: that view does not live‑render progress artifact updates. The artifact data is correct (as you observed when opening the artifact), but the circle in that run details view doesn’t update until the next UI poll or a manual refresh. What you can do right now - Verify where it does show: open the task run details and go to its Artifacts tab, or the global Artifacts page (since you added a key). Those views fetch fresh data when opened and on their own polling cadence. - Throttle by time, not just item count: even with “every 5 URLs”, high concurrency can still fire updates faster than the UI polls. Consider updating at most every ~1–2 seconds.
Copy code
import time
  last_sent = 0.0

  # inside your async update block
  now = time.monotonic()
  if (now - last_sent) >= 2.0 or downloaded_urls == len(urls):
      last_sent = now
      await aupdate_progress_artifact(
          artifact_id=progress_artifact_id,
          progress=(downloaded_urls / len(urls)) * 100
      )
- Hard refresh the page (Shift + Reload) or navigate away/back if you need to see changes immediately. - Confirm you’re on the latest 3.x; improvements land regularly. Run
prefect version
. If you want to double‑check the backend is updating (it is), you can hit the API directly:
Copy code
# by artifact id
curl <http://127.0.0.1:4200/api/artifacts/<ARTIFACT_ID>>

# by key (list latest first)
curl "<http://127.0.0.1:4200/api/artifacts?key=frentes-membros-progress&sort=desc>"
API refs: Read artifacts, Update artifact. If the circle still never advances without a manual refresh on the run details page, that’s likely a UI limitation/bug in that specific view. If you can share: - Prefect version (
prefect version
) - UI build (self-host URL/port, e.g., 127.0.0.1:4200) - Which exact page is “stuck” (run details -> task bars vs Artifacts tab vs global Artifacts) - Browser/version …I can open an issue with the UI team on your behalf. Also — what’s your other question? Happy to help.
g
The other question is: frentes_membros recieves the ids of frentes to mount the urls and download them async. but in the flow run page, where we have the live graph of the run, when it finishes downloading frentes i need to use the mouse to click the arrow to expand and see the taks of frentes_membros. Why? I want it to automatically show in the graph. Heres my pipeline where im calling the tasks: from prefect import flow, get_run_logger from prefect.task_runners import ThreadPoolTaskRunner from prefect.futures import resolve_futures_to_results from datetime import date, datetime from typing import Any, cast from tasks.tse import TSE_ENDPOINTS, extract_tse from tasks.camara.legislatura import extract_legislatura from tasks.camara.deputados import extract_deputados from tasks.camara.frentes import extract_frentes from tasks.camara.frentes_membros import extract_frentes_membros from config.loader import load_config APP_SETTINGS = load_config() # IMPORTAR TASKS TSE, CONGRESSO, SENADO ETC... @flow( task_runner=ThreadPoolTaskRunner(max_workers=APP_SETTINGS.FLOW.MAX_RUNNERS), # type: ignore log_prints=True ) async def pipeline( date: date = datetime.now().date(), refresh_cache: bool = False ): logger = get_run_logger() logger.info("Iniciando pipeline") # TSE: ~30 endpoints em paralelo tse_fs = [ cast(Any, extract_tse) .with_options(refresh_cache=refresh_cache) .submit(name, url) for name, url in TSE_ENDPOINTS.items() ] # CONGRESSO legislatura = extract_legislatura(date) deputados_f = extract_deputados.submit(legislatura) frentes_f = extract_frentes.submit(legislatura) frentes_membros_f = extract_frentes_membros.submit(cast(Any, frentes_f)) return resolve_futures_to_results({ "tse": tse_fs, "congresso_deputados": deputados_f, "congresso_frentes": frentes_f, "congresso_frentes_membros": frentes_membros_f, }) if name == "__main__": pipeline.serve( # type: ignore name="deploy-1" )
@Marvin The other question is: frentes_membros recieves the ids of frentes to mount the urls and download them async. but in the flow run page, where we have the live graph of the run, when it finishes downloading frentes i need to use the mouse to click the arrow to expand and see the taks of frentes_membros. Why? I want it to automatically show in the graph. Heres my pipeline where im calling the tasks: from prefect import flow, get_run_logger from prefect.task_runners import ThreadPoolTaskRunner from prefect.futures import resolve_futures_to_results from datetime import date, datetime from typing import Any, cast from tasks.tse import TSE_ENDPOINTS, extract_tse from tasks.camara.legislatura import extract_legislatura from tasks.camara.deputados import extract_deputados from tasks.camara.frentes import extract_frentes from tasks.camara.frentes_membros import extract_frentes_membros from config.loader import load_config APP_SETTINGS = load_config() # IMPORTAR TASKS TSE, CONGRESSO, SENADO ETC... @flow( task_runner=ThreadPoolTaskRunner(max_workers=APP_SETTINGS.FLOW.MAX_RUNNERS), # type: ignore log_prints=True ) async def pipeline( date: date = datetime.now().date(), refresh_cache: bool = False ): logger = get_run_logger() logger.info("Iniciando pipeline") # TSE: ~30 endpoints em paralelo tse_fs = [ cast(Any, extract_tse) .with_options(refresh_cache=refresh_cache) .submit(name, url) for name, url in TSE_ENDPOINTS.items() ] # CONGRESSO legislatura = extract_legislatura(date) deputados_f = extract_deputados.submit(legislatura) frentes_f = extract_frentes.submit(legislatura) frentes_membros_f = extract_frentes_membros.submit(cast(Any, frentes_f)) return resolve_futures_to_results({ "tse": tse_fs, "congresso_deputados": deputados_f, "congresso_frentes": frentes_f, "congresso_frentes_membros": frentes_membros_f, }) if name == "__main__": pipeline.serve( # type: ignore name="deploy-1" )
m
✅ thought for 35.0 seconds
Thanks — that helps. On your second question: What you’re seeing is the current UI behavior. On the flow run page: - Timeline tab (the one with the horizontal bars) groups tasks into collapsible rows for readability and performance. It does not auto-expand when a downstream task (like frentes_membros) starts; you have to click the arrow to reveal the child tasks. - Graph tab shows the DAG dependencies live, but it also won’t auto-expand collapsed groups by itself. There isn’t a setting today to auto-expand those groups as tasks start. Workarounds: - Watch the Graph tab to see when frentes_membros becomes scheduled/running, then click it to navigate to the task run details. - In the Timeline, expand the relevant group once; it will stay expanded while you’re on that page. If you navigate away, you’ll need to expand again. - If you want clearer visibility, consider making frentes_membros a subflow (purely for UX hierarchy); you’d still click into it, but the separation can make it easier to spot in the Graph. If you’d like, I can file a feature request to: - Auto-expand a task group when its first child starts - Or add a “Expand all/remember expanded state” toggle on the Timeline/Graph views Let me know your Prefect version and I’ll open it with the UI team.
g
@Marvin
Copy code
from pathlib import Path
from typing import cast
from uuid import UUID
from prefect import task, get_run_logger
from prefect.artifacts import create_progress_artifact
from datetime import timedelta

from <http://utils.io|utils.io> import download_stream
from utils.br_data import BR_STATES, ELECTIONS_YEARS
from config.loader import load_config, CACHE_POLICY_MAP

APP_SETTINGS = load_config()

# MONTAR ENDPOINT REDES SOCIAIS POR ESTADO
REDES_SOCIAIS_ENDPOINTS = {
    f"redes_sociais_{year}_{state}": f"{APP_SETTINGS.TSE.BASE_URL}consulta_cand/rede_social_candidato_{year}_{state}.zip"
    for state in BR_STATES
    for year in ELECTIONS_YEARS
}

TSE_ENDPOINTS = {
    "candidatos_2018": f"{APP_SETTINGS.TSE.BASE_URL}consulta_cand/consulta_cand_2018.zip",
    "candidatos_2022": f"{APP_SETTINGS.TSE.BASE_URL}consulta_cand/consulta_cand_2022.zip",
    "prestaca_contas_2018": f"{APP_SETTINGS.TSE.BASE_URL}prestacao_contas/prestacao_de_contas_eleitorais_candidatos_2018.zip",
    "prestacao_contas_2022": f"{APP_SETTINGS.TSE.BASE_URL}prestacao_contas/prestacao_de_contas_eleitorais_candidatos_2022.zip",
    "resultado_eleicao_2018": f"{APP_SETTINGS.TSE.BASE_URL}votacao_candidato_munzona/votacao_candidato_munzona_2018.zip",
    "resultado_eleicao_2022": f"{APP_SETTINGS.TSE.BASE_URL}votacao_candidato_munzona/votacao_candidato_munzona_2022.zip"
}

TSE_ENDPOINTS = TSE_ENDPOINTS | REDES_SOCIAIS_ENDPOINTS

@task(
    task_run_name="extract_tse_{name}",
    retries=APP_SETTINGS.TSE.RETRIES,
    retry_delay_seconds=APP_SETTINGS.TSE.RETRY_DELAY,
    timeout_seconds=APP_SETTINGS.TSE.TIMEOUT,
    cache_policy=CACHE_POLICY_MAP[APP_SETTINGS.TSE.CACHE_POLICY],
    cache_expiration=timedelta(days=APP_SETTINGS.TSE.CACHE_EXPIRATION)
)
def extract_tse(name: str, url: str, out_dir: str = "data/tse") -> str:
    logger = get_run_logger()

    progress_id = create_progress_artifact(
        progress=0.0,
        description=f"Download do arquivo {name}, do TSE"
    )

    dest = Path(out_dir) / f"{name}.zip"
    <http://logger.info|logger.info>(f"Fazendo download  do endpoint TSE '{url}' -> {dest}")
    dest_path = download_stream(url, dest, unzip=True, progress_artifact_id=cast(UUID, progress_id))
    return dest_path
# TSE: ~30 endpoints em paralelo tse_fs = [ cast(Any, extract_tse) .with_options(refresh_cache=refresh_cache) .submit(name, url) for name, url in TSE_ENDPOINTS.items() ] Acima tenho uma função a qual quero criar um artefato agregado de todos esses downloads feitos em paralelo. Como posso juntar esses dados em um artefato prefect ou atualizar uma tabela de artefato ja criado?
m
✅ thought for 135.9 seconds
Great question. Short answer: only Progress artifacts support in-place updates. For an aggregate “table of all TSE downloads,” you’ll create a Table artifact once you have the parallel results, and if you need to refresh it later, create a new version using the same key. Two good patterns you can choose from: Option A — Aggregate at the flow level after all tasks complete - Submit your ~30 extract_tse tasks in parallel. - Resolve their results in the flow. - Build a table and create a single Table artifact with a stable key. Example:
Copy code
from prefect import flow
from prefect.futures import resolve_futures_to_results
from prefect.artifacts import create_table_artifact

@flow
def pipeline(...):
    # submit tasks
    tse_fs = [
        extract_tse.with_options(refresh_cache=refresh_cache).submit(name, url)
        for name, url in TSE_ENDPOINTS.items()
    ]

    # resolve results to paths (or whatever you want in the table)
    results = resolve_futures_to_results(tse_fs)

    # build a table: list[dict] works nicely
    table = [
        {"name": name, "url": url, "path": path}
        for (name, url), path in zip(TSE_ENDPOINTS.items(), results)
    ]

    # create/update the aggregate table artifact (new version each run)
    create_table_artifact(
        table=table,
        key="tse-downloads-summary",
        description="Resumo dos downloads TSE desta execução"
    )

    return results
Option B — Stream partials and republish new versions - If you want the table to evolve during the run, you can publish a new table artifact with the same key as chunks complete. This does NOT update in place; it creates new versions and the UI will show the latest version. - Throttle how often you republish (e.g., every N completions or every few seconds) to avoid spamming. Notes and limitations - Table, Markdown, Link, and Image artifacts are immutable; re-creating with the same key creates a new version. - Only Progress artifacts can be updated in place (
update_progress_artifact
or
aupdate_progress_artifact
). - To retrieve the latest version later, you can query by key (e.g.,
Artifact.get(key="tse-downloads-summary")
) if you need it downstream. If you want a row-per-file download with live-ish updates, I recommend Option A for simplicity. If you really want incremental visibility, do a periodic republish:
Copy code
from prefect.artifacts import create_table_artifact
from time import monotonic

last_pub = 0.0
def maybe_publish(partial_rows):
    global last_pub
    now = monotonic()
    if now - last_pub >= 5.0 or len(partial_rows) == TOTAL:
        create_table_artifact(
            table=partial_rows,
            key="tse-downloads-summary",
            description="Resumo parcial dos downloads TSE"
        )
        last_pub = now
If you share what columns you want in the summary (e.g., name, url, destination path, bytes, duration, success/error), I can draft the exact table building code.
g
@Marvin def frentes_membros_urls(frentes_ids: list[str]) -> list[str]: return [f"{APP_SETTINGS.CAMARA.REST_BASE_URL}frentes/{id}/membros" for id in frentes_ids] @task( retries=APP_SETTINGS.CAMARA.RETRIES, retry_delay_seconds=APP_SETTINGS.CAMARA.RETRY_DELAY, timeout_seconds=APP_SETTINGS.CAMARA.TIMEOUT ) async def extract_frentes_membros(frentes_ids: list[str], out_dir: str | Path = "data/camara") -> str: logger = get_run_logger() progress_id = await acreate_progress_artifact( progress=0.0, description="Download de membros de frentes da Câmara" ) assert isinstance(progress_id, UUID) urls = frentes_membros_urls(frentes_ids) logger.info(f"Congresso: buscando Membros de {len(urls)} Frentes") jsons = await fetch_json_many_async( urls=urls, concurrency=APP_SETTINGS.CAMARA.CONCURRENCY, timeout=APP_SETTINGS.CAMARA.TIMEOUT, follow_pagination=True, progress_artifact_id=progress_id ) await aupdate_progress_artifact( artifact_id=progress_id, progress=100.0, description="Downloads concluídos" ) artifact_data = [] for json in jsons: json = cast(dict, json) link_self = next(l["href"] for l in json.get("links", []) if l.get("rel") == "self") id_frente = link_self.split("/")[-2] membros = json.get("data", []) # type: ignore artifact_data.append({ "Id Frente": id_frente, "Quantidade Membros": len(membros) }) create_table_artifact( key="frentes_membros", table=artifact_data, description="Total de membros encontrados nas frentes." ) dest = Path(out_dir) / "frentes_membros.ndjson" return save_ndjson(cast(list[dict], jsons), dest) The artifact wasnt created. it said: RuntimeWarning: coroutine 'acreate_table_artifact' was never awaited create_table_artifact( RuntimeWarning: Enable tracemalloc to get the object allocation traceback It is obligatory to use acreate_table_artifact in this case? Why?
m
✅ thought for 32.6 seconds
You’re in an async task, so Prefect’s sync-compatible functions will return a coroutine when called from that context. That’s why you saw “coroutine 'acreate_table_artifact' was never awaited” at the line where you called create_table_artifact(...). Answer to “Is it obligatory to use acreate_table_artifact here? Why?” - Not strictly obligatory to import the async name, but you must await the call in an async context. - Prefect decorates many functions as “sync-compatible”. In a sync context you just call create_table_artifact(...). In an async context, the same call returns an awaitable (the underlying acreate_table_artifact), so you must await it. If you don’t, you get that RuntimeWarning and nothing is created. Two equivalent fixes - Explicit async variant:
Copy code
from prefect.artifacts import acreate_table_artifact

# ...
artifact_id = await acreate_table_artifact(
    key="frentes_membros",
    table=artifact_data,
    description="Total de membros encontrados nas frentes."
)
- Or keep the sync name but await it in async code:
Copy code
from prefect.artifacts import create_table_artifact

# ...
artifact_id = await create_table_artifact(
    key="frentes_membros",
    table=artifact_data,
    description="Total de membros encontrados nas frentes."
)
Tip - Table artifacts are immutable; calling (a)create_table_artifact again with the same key creates a new version, not an in-place update. - Keep using the async variants consistently inside async tasks: you already do this for progress artifacts, so mirroring that for table artifacts is a good practice. Docs - How-to: Create and use artifacts - API: prefect.artifacts API
g
@Marvin from prefect import flow, get_run_logger from prefect.task_runners import ThreadPoolTaskRunner from prefect.futures import resolve_futures_to_results from datetime import date, datetime from typing import Any, cast from tasks.tse import TSE_ENDPOINTS, extract_tse from tasks.camara.legislatura import extract_legislatura from tasks.camara.deputados import extract_deputados from tasks.camara.frentes import extract_frentes from tasks.camara.frentes_membros import extract_frentes_membros from tasks.camara.assiduidade import extract_assiduidade_deputados from config.loader import load_config APP_SETTINGS = load_config() # IMPORTAR TASKS TSE, CONGRESSO, SENADO ETC... @flow( task_runner=ThreadPoolTaskRunner(max_workers=APP_SETTINGS.FLOW.MAX_RUNNERS), # type: ignore log_prints=True ) async def pipeline( date: date = datetime.now().date(), refresh_cache: bool = False ): logger = get_run_logger() logger.info("Iniciando pipeline") # TSE: ~30 endpoints em paralelo tse_fs = [ cast(Any, extract_tse) .with_options(refresh_cache=refresh_cache) .submit(name, url) for name, url in TSE_ENDPOINTS.items() ] # CONGRESSO legislatura = extract_legislatura(date) deputados_f = extract_deputados.submit(legislatura) assiduidade_f = extract_assiduidade_deputados.submit(deputados_f, legislatura) frentes_f = extract_frentes.submit(legislatura) frentes_membros_f = extract_frentes_membros.submit(cast(Any, frentes_f)) return resolve_futures_to_results({ "tse": tse_fs, "congresso_deputados": deputados_f, "congresso_assiduidade": assiduidade_f, "congresso_frentes": frentes_f, "congresso_frentes_membros": frentes_membros_f, }) if name == "__main__": pipeline.serve( # type: ignore name="deploy-1" ) Im having a problem in: assiduidade_f = extract_assiduidade_deputados.submit(deputados_f, legislatura) because deputados_f is a PrefectFuture of a list of ids but i need to pass it to assiduidade. Also another question. Assiduidade should run in parallel to all other tasks right after deputados finishes. How this can be accomplished?
m
✅ thought for 52.5 seconds
You’ve got two related things here: passing a future into another task, and fanning out work after that future resolves. 1) Passing a PrefectFuture to a task - It’s valid to pass a PrefectFuture as an argument to another task. Prefect will resolve it to the underlying value right before the downstream task runs. - So this is fine if your downstream task consumes the whole list in a single task:
Copy code
deputados_f = extract_deputados.submit(legislatura)
  assiduidade_f = extract_assiduidade_deputados.submit(deputados_f, legislatura)
This creates a dependency: assiduidade waits for deputados to finish, then starts. It will run in parallel with all other branches that don’t depend on deputados. 2) If you want per-id parallelism for assiduidade Tasks cannot schedule other tasks (only flows can). To fan out per-id work, use a subflow or resolve the list in the flow, then submit per-id tasks. Option A: Subflow that fans out - Make a small subflow that takes the list of IDs and submits per-id tasks. Then call that subflow with the future; Prefect will start the subflow as soon as deputados finishes.
Copy code
from prefect import flow, task
from prefect.futures import resolve_futures_to_results

@task
def extract_assiduidade_for_one(dep_id: str, legislatura: int) -> dict:
    # ... compute one deputado’s assiduidade ...
    return {"id": dep_id, "ok": True}

@flow
def extract_assiduidade_deputados(ids: list[str], legislatura: int) -> list[dict]:
    fs = [extract_assiduidade_for_one.submit(i, legislatura) for i in ids]
    return resolve_futures_to_results(fs)

# In your main flow:
deputados_f = extract_deputados.submit(legislatura)
assiduidade_f = extract_assiduidade_deputados.submit(deputados_f, legislatura)
- This runs “right after deputados finishes” and in parallel with other tasks already running. Option B: Await the future in the flow, then submit per-id tasks - Since your flow is async, you can await just that future to get the list and immediately fan out. Other tasks submitted earlier keep running in parallel.
Copy code
deputados_ids = await deputados_f.result()  # or just await deputados_f if you prefer
assiduidade_fs = [extract_assiduidade_for_one.submit(i, legislatura) for i in deputados_ids]
Which should you choose? - If you want assiduidade as a single aggregated task, keep your original pattern with
.submit(deputados_f, legislatura)
. - If you want per-id parallelism, use the subflow pattern (Option A) or await-and-fan-out (Option B). Don’t call
.submit
inside a task; it won’t schedule child tasks. Notes - Your ThreadPoolTaskRunner will run these tasks concurrently as they are submitted. - Avoid calling
.result()
on futures too early in the flow unless you intend to gate subsequent scheduling on that completion. In your case, awaiting just
deputados_f
to fan out its dependents is perfectly fine; everything else already submitted keeps running. If you share the signature of
extract_assiduidade_deputados
(what it expects and returns), I can tailor the exact code for either single-task or per-id parallel patterns.
g
@Marvin ok got it about passing future values. from pathlib import Path from prefect import task, get_run_logger from prefect.artifacts import ( acreate_progress_artifact, aupdate_progress_artifact, acreate_table_artifact ) from prefect.futures import PrefectFuture from typing import cast import re from selectolax.parser import HTMLParser from utils.io import fetch_html_many_async, save_ndjson from config.loader import load_config APP_SETTINGS = load_config() def assiduidade_urls(deputados_ids: list[str], leg_years: list[int]) -> list[str]: return [ f"{APP_SETTINGS.CAMARA.PORTAL_BASE_URL}deputados/{id}/presenca-plenario/{year}" for id in deputados_ids for year in leg_years ] @task( retries=APP_SETTINGS.CAMARA.RETRIES, retry_delay_seconds=APP_SETTINGS.CAMARA.RETRY_DELAY, timeout_seconds=APP_SETTINGS.CAMARA.TIMEOUT ) async def extract_assiduidade_deputados( deputados_ids: list[str] | PrefectFuture[list[int]], legislatura: dict, out_dir: str | Path = "data/camara/assiduidade" ) -> str: logger = get_run_logger() leg_years = legislatura.get("dados", []).get("anosPassados") progress_id = await acreate_progress_artifact( progress=0.0, description="Progresso do download da assiduiadde de deputados" ) urls = assiduidade_urls(deputados_ids, leg_years) logger.info(f"Câmara: buscando assiduidade de {len(deputados_ids)} deputados dos anos {leg_years}") htmls = await fetch_html_many_async( urls=urls, concurrency=APP_SETTINGS.CAMARA.CONCURRENCY, timeout=APP_SETTINGS.CAMARA.TIMEOUT, progress_artifact_id=progress_id ) await aupdate_progress_artifact( artifact_id=progress_id, progress=100.0, description="Downloads concluídos" ) href_pattern = re.compile(r'https://www\.camara\.leg\.br/deputados/\d+') *id_ano_pattern= r'/deputados/(?P<id>\d+)\?.ano=(?P<ano>\d+)' json_results = [] for html in htmls: tree = HTMLParser(cast(str, html)) all_links = tree.css("a") for link in all_links: href = link.attributes.get("href", "") if isinstance(href, str): if href_pattern.match(href): match = re.search(id_ano_pattern, href) if match: deputado_id = int(match.group('id')) ano = int(match.group('ano')) json_results.append({ "deputado_id": deputado_id, "ano": ano, "html": html }) else: logger.warning(f"Não foram encontrados dados suficientes na pågina HTML") else: logger.warning(f"O href {href} não Ê string") dest = Path(out_dir) / "assiduidade.ndjson" dest_path = save_ndjson(json_results, dest) return dest_path This is the function but pylance is agnry about the types: Argument of type "list[str] | PrefectFuture[list[int]]" cannot be assigned to parameter "deputados_ids" of type "list[str]" in function "assiduidade_urls" Type "list[str] | PrefectFuture[list[int]]" is not assignable to type "list[str]" "PrefectFuture[list[int]]" is not assignable to "list[str]"Pylance*reportArgumentType* (parameter) deputados_ids: list[str] | PrefectFuture[list[int]]
m
✅ thought for 46.9 seconds
You’re seeing that Pylance error because the task parameter is annotated as a union with PrefectFuture, then you pass it to a function that expects a concrete list[str]. But at runtime, Prefect will always resolve futures before calling your task. So your task should be typed to receive concrete values (not futures). What to change - Do not type your task parameter as PrefectFuture[…]. Type it as a concrete sequence/list. - If your IDs might be ints in some places, normalize them to str before building URLs. - Access legislatura safely and default to an empty list if missing. Cleaned-up version
Copy code
from pathlib import Path
from typing import Sequence, cast
from uuid import UUID
from prefect import task, get_run_logger
from prefect.artifacts import (
    acreate_progress_artifact,
    aupdate_progress_artifact,
)
from selectolax.parser import HTMLParser
import re

from <http://utils.io|utils.io> import fetch_html_many_async, save_ndjson
from config.loader import load_config

APP_SETTINGS = load_config()

def assiduidade_urls(deputados_ids: Sequence[str], leg_years: Sequence[int]) -> list[str]:
    return [
        f"{APP_SETTINGS.CAMARA.PORTAL_BASE_URL}deputados/{dep_id}/presenca-plenario/{year}"
        for dep_id in deputados_ids
        for year in leg_years
    ]

@task(
    retries=APP_SETTINGS.CAMARA.RETRIES,
    retry_delay_seconds=APP_SETTINGS.CAMARA.RETRY_DELAY,
    timeout_seconds=APP_SETTINGS.CAMARA.TIMEOUT,
)
async def extract_assiduidade_deputados(
    deputados_ids: list[str],
    legislatura: dict,
    out_dir: str | Path = "data/camara/assiduidade",
) -> str:
    logger = get_run_logger()

    # Get years safely
    leg_years = legislatura.get("dados", {}).get("anosPassados", [])
    if not isinstance(leg_years, list):
        leg_years = []

    # Normalize IDs to strings
    dep_ids = [str(i) for i in deputados_ids]

    progress_id = await acreate_progress_artifact(
        progress=0.0,
        description="Progresso do download da assiduidade de deputados",
    )
    <http://logger.info|logger.info>(f"Progress artifact id: {progress_id}")

    urls = assiduidade_urls(dep_ids, cast(list[int], leg_years))
    <http://logger.info|logger.info>(f"Câmara: buscando assiduidade de {len(dep_ids)} deputados dos anos {leg_years}")

    htmls = await fetch_html_many_async(
        urls=urls,
        concurrency=APP_SETTINGS.CAMARA.CONCURRENCY,
        timeout=APP_SETTINGS.CAMARA.TIMEOUT,
        progress_artifact_id=progress_id,
    )

    await aupdate_progress_artifact(
        artifact_id=progress_id,
        progress=100.0,
        description="Downloads concluĂ­dos",
    )

    href_pattern = re.compile(r"<https://www>\.camara\.leg\.br/deputados/\d+")
    id_ano_pattern = r"/deputados/(?P<id>\d+)\?.*ano=(?P<ano>\d+)"

    json_results = []
    for html in htmls:
        tree = HTMLParser(cast(str, html))
        for link in tree.css("a"):
            href = link.attributes.get("href", "") or ""
            if not isinstance(href, str):
                logger.warning(f"O href {href} nĂŁo ĂŠ string")
                continue
            if href_pattern.match(href):
                match = re.search(id_ano_pattern, href)
                if match:
                    deputado_id = int(match.group("id"))
                    ano = int(match.group("ano"))
                    json_results.append({"deputado_id": deputado_id, "ano": ano, "html": html})
                else:
                    logger.warning("NĂŁo foram encontrados dados suficientes na pĂĄgina HTML")

    dest = Path(out_dir) / "assiduidade.ndjson"
    dest_path = save_ndjson(json_results, dest)
    return dest_path
At the call site - Keep your pipeline code passing the future directly; Prefect will resolve it. If Pylance complains at the submit line, silence it with a cast just at the call site (only for the linter; runtime is fine): ``` assiduidade_f = extract_assiduidade_deputados.submit(cast(list[str], deputados_f), legislatura) # or, more broadly:
assiduidade_f = extract_assiduidade_deputados.submit(cast(Any, deputados_f), legislatura) ``` Even better: make sure your
extract_deputados
task is annotated to return
list[str]
, so the future is
PrefectFuture[list[str]]
. Then use a small helper subflow to fan out per-id if needed (we discussed patterns earlier). Why not accept PrefectFuture in the task signature? - Tasks should receive materialized values. Prefect handles resolving futures automatically before invoking your task. Including PrefectFuture in the type hints confuses static type checkers and your own code, as you saw when you tried to use the union in places that expect concrete types. If you want, paste the signature and return type of
extract_deputados
and I’ll align the types end-to-end to make Pylance happy at both the definition and submit sites.
g
@Marvin File "C:\Projetos\magnetron\.venv\Lib\site-packages\prefect\utilities\timeout.py", line 32, in timeout_async raise timeout_exc_type(f"Scope timed out after {seconds} second(s).") prefect.task_engine.TaskRunTimeoutError: Scope timed out after 120.0 second(s). i got this error on exectuting assuduidade what it could be? from pathlib import Path from prefect import task, get_run_logger from prefect.artifacts import ( acreate_progress_artifact, aupdate_progress_artifact, acreate_table_artifact ) from typing import cast import re from selectolax.parser import HTMLParser from utils.io import fetch_html_many_async, save_ndjson from config.loader import load_config APP_SETTINGS = load_config() def assiduidade_urls(deputados_ids: list[str], leg_years: list[int]) -> list[str]: return [ f"{APP_SETTINGS.CAMARA.PORTAL_BASE_URL}deputados/{id}/presenca-plenario/{year}" for id in deputados_ids for year in leg_years ] @task( retries=APP_SETTINGS.CAMARA.RETRIES, retry_delay_seconds=APP_SETTINGS.CAMARA.RETRY_DELAY, timeout_seconds=APP_SETTINGS.CAMARA.TIMEOUT ) async def extract_assiduidade_deputados( deputados_ids: list[str], legislatura: dict, out_dir: str | Path = "data/camara/assiduidade" ) -> str: logger = get_run_logger() leg_years = legislatura.get("dados", [])[0].get("anosPassados") progress_id = await acreate_progress_artifact( progress=0.0, description="Progresso do download da assiduiadde de deputados" ) urls = assiduidade_urls(deputados_ids, leg_years) logger.info(f"Câmara: buscando assiduidade de {len(deputados_ids)} deputados dos anos {leg_years}") htmls = await fetch_html_many_async( urls=urls, concurrency=APP_SETTINGS.CAMARA.CONCURRENCY, timeout=APP_SETTINGS.CAMARA.TIMEOUT, progress_artifact_id=progress_id ) await aupdate_progress_artifact( artifact_id=progress_id, progress=100.0, description="Downloads concluídos" ) href_pattern = re.compile(r'https://www\.camara\.leg\.br/deputados/\d+') *id_ano_pattern= r'/deputados/(?P<id>\d+)\?.ano=(?P<ano>\d+)' json_results = [] for html in htmls: tree = HTMLParser(cast(str, html)) all_links = tree.css("a") for link in all_links: href = link.attributes.get("href", "") if isinstance(href, str): if href_pattern.match(href): match = re.search(id_ano_pattern, href) if match: deputado_id = int(match.group('id')) ano = int(match.group('ano')) json_results.append({ "deputado_id": deputado_id, "ano": ano, "html": html }) else: logger.warning(f"Não foram encontrados dados suficientes na pågina HTML") else: logger.warning(f"O href {href} não Ê string") dest = Path(out_dir) / "assiduidade.ndjson" dest_path = save_ndjson(json_results, dest) return dest_path
m
✅ thought for 267.5 seconds
That error means your task hit its Prefect task timeout, not an HTTP timeout. The value comes from your @task decorator: timeout_seconds=APP_SETTINGS.CAMARA.TIMEOUT. If that setting is 120, Prefect will cancel the task after 120s of wall-clock runtime and raise TaskRunTimeoutError. Why this is happening - You’re doing a lot of work: building many URLs and fetching them concurrently, parsing HTML, and writing output. Even if each HTTP request has its own per-request timeout, the total task runtime can easily exceed 120s. - You’re reusing the same TIMEOUT value for both HTTP requests and the overall task. Those are very different time scales. How to fix - Increase the task timeout or disable it, and keep a separate (usually smaller) HTTP timeout. Options: 1) Raise the task timeout in the decorator
Copy code
@task(
    retries=APP_SETTINGS.CAMARA.RETRIES,
    retry_delay_seconds=APP_SETTINGS.CAMARA.RETRY_DELAY,
    timeout_seconds=APP_SETTINGS.CAMARA.TASK_TIMEOUT_SECONDS,  # e.g., 900
)
…and use a different setting for HTTP per-request timeout:
Copy code
htmls = await fetch_html_many_async(
    urls=urls,
    concurrency=APP_SETTINGS.CAMARA.CONCURRENCY,
    timeout=APP_SETTINGS.CAMARA.HTTP_TIMEOUT_SECONDS,  # e.g., 20–60
    progress_artifact_id=progress_id,
)
2) Override per submission
Copy code
assiduidade_f = extract_assiduidade_deputados.with_options(
    timeout_seconds=900  # or None to disable
).submit(deputados_f, legislatura)
3) Split the work into smaller tasks - Chunk the URLs and submit multiple tasks (e.g., 200 URLs per task). Each task finishes faster and stays below the timeout. - Or use a subflow to fan out and collect results, as discussed earlier. Helpful sanity checks - Log the number of URLs and your concurrency: - total_urls = len(deputados_ids) * len(leg_years) - With concurrency C and average per-request time T seconds, the lower bound is roughly ceil(total_urls / C) * T. Make sure your task timeout comfortably exceeds that. - Verify your years extraction is correct; the current line assumes a list at dados:
Copy code
# safer:
  leg_years = (legislatura.get("dados") or {}).get("anosPassados") or []
  # ensure ints
  leg_years = [int(y) for y in leg_years]
Using [...][0] will throw if dados isn’t a list. Summary - This is a Prefect task timeout. Increase timeout_seconds (or set to None) for the task, and keep a separate smaller HTTP timeout. - Consider chunking work or fanning out if total runtime is large.
g
@Marvin from pathlib import Path from prefect import task, get_run_logger from prefect.artifacts import ( acreate_progress_artifact, aupdate_progress_artifact, acreate_table_artifact ) from typing import cast import re from selectolax.parser import HTMLParser from utils.io import fetch_html_many_async, save_ndjson from config.loader import load_config APP_SETTINGS = load_config() def assiduidade_urls(deputados_ids: list[str], leg_years: list[int]) -> list[str]: return [ f"{APP_SETTINGS.CAMARA.PORTAL_BASE_URL}deputados/{id}/presenca-plenario/{year}" for id in deputados_ids for year in leg_years ] @task( retries=APP_SETTINGS.CAMARA.RETRIES, retry_delay_seconds=APP_SETTINGS.CAMARA.RETRY_DELAY, timeout_seconds=1800 ) async def extract_assiduidade_deputados( deputados_ids: list[str], legislatura: dict, out_dir: str | Path = "data/camara" ) -> str: logger = get_run_logger() leg_years = legislatura.get("dados", [])[0].get("anosPassados") progress_id = await acreate_progress_artifact( progress=0.0, description="Progresso do download da assiduiadde de deputados" ) urls = assiduidade_urls(deputados_ids, leg_years) logger.info(f"Câmara: buscando assiduidade de {len(deputados_ids)} deputados dos anos {leg_years}") htmls = await fetch_html_many_async( urls=urls, concurrency=APP_SETTINGS.CAMARA.CONCURRENCY, timeout=APP_SETTINGS.CAMARA.TIMEOUT, progress_artifact_id=progress_id ) await aupdate_progress_artifact( artifact_id=progress_id, progress=100.0, description="Downloads concluídos" ) href_pattern = re.compile(r'https://www\.camara\.leg\.br/deputados/\d+') *id_ano_pattern= r'/deputados/(?P<id>\d+)\?.ano=(?P<ano>\d+)' # Montando os resultados JSON e o artefato artifact_data = [] json_results = [] for html in htmls: tree = HTMLParser(cast(str, html)) all_links = tree.css("a") for link in all_links: href = link.attributes.get("href", "") if isinstance(href, str): if href_pattern.match(href): match = re.search(id_ano_pattern, href) if match: deputado_id = int(match.group('id')) year = int(match.group('ano')) json_results.append({ "deputado_id": deputado_id, "ano": year, "html": html }) tables = tree.css('table.table.table-bordered') name = tree.css_first("h1.titulo-internal") name_text = name.text(strip=True) if name else None artifact_row = { "id": deputado_id, "nome": name_text, "ano": year } if tables: artifact_row["possui_dados"] = "Sim" else: artifact_row["possui_dados"] = "Não" artifact_data.append(artifact_row) else: logger.warning(f"Não foram encontrados dados suficientes na pågina HTML") else: logger.warning(f"O href {href} não Ê string") await acreate_table_artifact( key="assiduidade", table=artifact_data, description="Assiduidade de deputados" ) dest = Path(out_dir) / "assiduidade.ndjson" dest_path = save_ndjson(json_results, dest) return dest_path I have this task. It downloads data based on year. I want to cache the results from past years because they dont change, i want only always fetch new data of the current year.
m
✅ thought for 147.4 seconds
Great use case. You can cache per-year so past years are reused while the current year is always refreshed using a custom cache key function. In Prefect 3.x you can do this either inline on the task via cache_key_fn, or by defining a reusable CachePolicy. Key idea - Return a stable key for past years (so they reuse cached results). - Return None for the current year (so Prefect skips cache for that run). - Optionally set cache_expiration for how long to keep cached entries. Option A: Use cache_key_fn directly on the task
Copy code
from datetime import date
from prefect import task
from prefect.utilities.hashing import hash_objects

def assiduidade_cache_key(context, params) -> str | None:
    # Expect params to include legislatura (dict) and deputados_ids (list[str])
    today_year = date.today().year
    leg_years = (params.get("legislatura") or {}).get("dados", {}).get("anosPassados") or []
    dep_ids = params.get("deputados_ids") or []

    # If any requested year is current year, skip caching this run entirely
    # (You could be more granular if you split per-year work into separate tasks)
    if any(int(y) == today_year for y in leg_years):
        return None

    # Otherwise, cache by the combination of dep_ids and leg_years
    # Normalize to strings and a stable ordering
    key = hash_objects(sorted(map(str, dep_ids)), sorted(int(y) for y in leg_years))
    return f"assiduidade:{key}"

@task(
    retries=APP_SETTINGS.CAMARA.RETRIES,
    retry_delay_seconds=APP_SETTINGS.CAMARA.RETRY_DELAY,
    timeout_seconds=1800,
    cache_key_fn=assiduidade_cache_key,
    cache_expiration=timedelta(days=30),  # adjust to taste
)
async def extract_assiduidade_deputados(...):
    ...
Option B: Define a CachePolicy (reusable)
Copy code
from datetime import date, timedelta
from prefect.cache_policies import CachePolicy
from prefect.utilities.hashing import hash_objects

def assiduidade_cache_key(context, params) -> str | None:
    today_year = date.today().year
    leg_years = (params.get("legislatura") or {}).get("dados", {}).get("anosPassados") or []
    dep_ids = params.get("deputados_ids") or []
    if any(int(y) == today_year for y in leg_years):
        return None
    key = hash_objects(sorted(map(str, dep_ids)), sorted(int(y) for y in leg_years))
    return f"assiduidade:{key}"

ASSIDUIDADE_CACHE = CachePolicy.from_cache_key_fn(
    assiduidade_cache_key,
    # optional: key_storage=..., isolation_level=..., lock_manager=...
)

@task(
    retries=APP_SETTINGS.CAMARA.RETRIES,
    retry_delay_seconds=APP_SETTINGS.CAMARA.RETRY_DELAY,
    timeout_seconds=1800,
    cache_policy=ASSIDUIDADE_CACHE,
    cache_expiration=timedelta(days=30),
)
async def extract_assiduidade_deputados(...):
    ...
Granular per-year caching (optional but cleaner) - Since you fetch multiple years in one task, skipping cache for “any current year present” disables cache entirely for that run. - If you want past years to be cached and only the current year to be fetched, split the work per-year: - Have the task accept a single year - Submit one task per year - Use cache_key_fn that keys by year; return None when year == current_year. Example:
Copy code
@task(cache_key_fn=lambda _, p: None if p["year"] == date.today().year else f"assiduidade:{p['year']}", cache_expiration=timedelta(days=180))
async def extract_assiduidade_year(deputados_ids: list[str], year: int) -> str:
    # do the same logic but for one year
    ...

# In your flow after you have dep_ids and leg_years:
fs = [extract_assiduidade_year.submit(dep_ids, y) for y in leg_years]
This gives you the best of both worlds: current-year runs fresh; past years reuse cache and don’t re-download. A few extra correctness tweaks for your current code - Safer access to years:
Copy code
leg_years = (legislatura.get("dados") or {}).get("anosPassados") or []
  leg_years = [int(y) for y in leg_years]
Your
[0]
indexing will crash if
dados
isn’t a list.
- Use the async table artifact creator (you already are) and await it. - Keep HTTP timeout separate from task timeout, as discussed earlier. Docs - Caching concepts: Caching concepts - Cache policies API: prefect.cache_policies - Hash helpers: prefect.utilities.hashing If you’d like, I can refactor your assiduidade task into the per-year pattern and wire it into your pipeline so only the current year runs fresh.
g
@Marvin from pathlib import Path from prefect import task, get_run_logger from prefect.artifacts import ( acreate_progress_artifact, aupdate_progress_artifact, acreate_table_artifact ) from typing import cast import re from selectolax.parser import HTMLParser from datetime import date, timedelta from utils.io import fetch_html_many_async, save_ndjson from config.loader import load_config APP_SETTINGS = load_config() def assiduidade_urls(deputados_ids: list[str], year: int) -> list[str]: return [ f"{APP_SETTINGS.CAMARA.PORTAL_BASE_URL}deputados/{id}/presenca-plenario/{year}" for id in deputados_ids ] @task( retries=APP_SETTINGS.CAMARA.RETRIES, retry_delay_seconds=APP_SETTINGS.CAMARA.RETRY_DELAY, timeout_seconds=1800, cache_key_fn=lambda _, year: None if year == date.today().year else f"assiduidade:{year}", cache_expiration=timedelta(days=180) ) async def extract_assiduidade_deputados( deputados_ids: list[str], legislatura_year: int, out_dir: str | Path = "data/camara" ) -> str: logger = get_run_logger() progress_id = await acreate_progress_artifact( progress=0.0, description="Progresso do download da assiduiadde de deputados" ) urls = assiduidade_urls(deputados_ids, legislatura_year) logger.info(f"Câmara: buscando assiduidade de {len(deputados_ids)} deputados do ano {legislatura_year}") htmls = await fetch_html_many_async( urls=urls, concurrency=APP_SETTINGS.CAMARA.CONCURRENCY, timeout=APP_SETTINGS.CAMARA.TIMEOUT, progress_artifact_id=progress_id ) await aupdate_progress_artifact( artifact_id=progress_id, progress=100.0, description="Downloads concluídos" ) href_pattern = re.compile(r'https://www\.camara\.leg\.br/deputados/\d+') *id_ano_pattern= r'/deputados/(?P<id>\d+)\?.ano=(?P<ano>\d+)' # Montando os resultados JSON e o artefato artifact_data = [] json_results = [] for html in htmls: tree = HTMLParser(cast(str, html)) all_links = tree.css("a") for link in all_links: href = link.attributes.get("href", "") if isinstance(href, str): if href_pattern.match(href): match = re.search(id_ano_pattern, href) if match: deputado_id = int(match.group('id')) year = int(match.group('ano')) json_results.append({ "deputado_id": deputado_id, "ano": year, "html": html }) tables = tree.css('table.table.table-bordered') name = tree.css_first("h1.titulo-internal") name_text = name.text(strip=True) if name else None artifact_row = { "id": deputado_id, "nome": name_text, "ano": year } if tables: artifact_row["possui_dados"] = "Sim" else: artifact_row["possui_dados"] = "Não" artifact_data.append(artifact_row) else: logger.warning(f"Não foram encontrados dados suficientes na pågina HTML") else: logger.warning(f"O href {href} não Ê string") await acreate_table_artifact( key="assiduidade", table=artifact_data, description="Assiduidade de deputados" ) dest = Path(out_dir) / "assiduidade.ndjson" dest_path = save_ndjson(json_results, dest) return dest_path And in my pipeline.py: # CONGRESSO legislatura = extract_legislatura(date) deputados_f = extract_deputados.submit(legislatura) anos_passados = legislatura.get("dados", [])[0].get("anosPassados", []) assiduidade_f = [ extract_assiduidade_deputados.submit( cast(Any, deputados_f), ano ) for ano in anos_passados ] The process gives me an error: OSError: [WinError 123] A sintaxe do nome do arquivo, do nome do diretório ou do rótulo do volume estå incorreta: "C:\\Users\\Gustavo Trabalho\\.prefect\\storage\\assiduidade:{'deputados_ids': [215043, 139... n error was encountered while committing transaction "assiduidade:{'deputados_ids': [215043, 139285, 157130, 73768, 227370, 210989,...
m
✅ thought for 35.3 seconds
You hit a Windows path error because your cache_key_fn is wrong. In Prefect 3.x, cache_key_fn receives (context, params_dict). In your code you wrote:
Copy code
cache_key_fn=lambda _, year: None if year == date.today().year else f"assiduidade:{year}"
Here, “year” is actually the entire params dict, so Prefect tries to use a cache key like:
Copy code
"assiduidade:{'deputados_ids': [...], 'legislatura_year': 2022, 'out_dir': '...'}"
That string contains braces, colons, commas, etc. Prefect stores cache data under ~/.prefect/storage/<cache_key>, so on Windows this becomes an invalid directory name and you get WinError 123. Fix: read the year from the params dict and return a filesystem-safe key. Also, prefer hashing inputs if you want the cache to reflect the full input set. Example (robust and Windows-safe)
Copy code
from datetime import date, timedelta
from prefect.utilities.hashing import hash_objects

def assiduidade_cache_key(_, params) -> str | None:
    # pull year from params dict
    year = int(params["legislatura_year"])
    # skip caching for the current year
    if year == date.today().year:
        return None

    # Include deputies list in the key so cache changes if inputs change
    dep_ids = [str(i) for i in params.get("deputados_ids", [])]
    deps_hash = hash_objects(sorted(dep_ids)) or "noids"

    # Return a short, filesystem-safe key (avoid :, {}, [], spaces, etc.)
    return f"assiduidade-{year}-{deps_hash}"

@task(
    retries=APP_SETTINGS.CAMARA.RETRIES,
    retry_delay_seconds=APP_SETTINGS.CAMARA.RETRY_DELAY,
    timeout_seconds=1800,
    cache_key_fn=assiduidade_cache_key,
    cache_expiration=timedelta(days=180),
)
async def extract_assiduidade_deputados(
    deputados_ids: list[str],
    legislatura_year: int,
    out_dir: str | Path = "data/camara",
) -> str:
    ...
If you truly want to cache only by year (regardless of which IDs you pass), you can simplify:
Copy code
@task(
    ...,
    cache_key_fn=lambda _, p: None if int(p["legislatura_year"]) == date.today().year
                              else f"assiduidade-{int(p['legislatura_year'])}",
    cache_expiration=timedelta(days=180)
)
Note the lambda’s second arg is p (the params dict), and the key avoids colons/braces. Why this is necessary - Prefect stores cache entries under a directory named by your key. Keys must be filesystem-safe, especially on Windows. - The cache_key_fn must accept (context, params_dict). Returning None skips caching for that run. Related docs - Caching concepts: Caching concepts - Cache policies API: prefect.cache_policies - Hash helpers: prefect.utilities.hashing If you want, I can also add a small validation that logs the computed cache key on task start, so you can quickly confirm it looks clean and behaves as expected.
g
@Marvin What about saving the ndjson? The three tasks are beign executed in parallel. Could it cause problems? Heres the function used to save the data: # Salva uma lista de JSONs em um Ăşnico NDJson def save_ndjson(records: list[dict], dest_path: str | Path) -> str: dest_path = Path(dest_path) ensure_dir(dest_path.parent) with open(dest_path, "w", encoding="utf-8") as f: for rec in records: f.write(json.dumps(rec, ensure_ascii=False) + "\n") return str(dest_path)
m
✅ thought for 29.4 seconds
Short answer: yes — if multiple tasks write to the same NDJSON path concurrently, you can get races, truncation, or last-writer-wins. On Windows, it’s especially brittle. Make each task write to a unique file, then merge in a downstream step. Also, if each per‑year task is creating a table artifact with the same key, you’ll end up with multiple versions; move artifact creation to an aggregate step or key by year. Safe pattern - In each per-year task: - Write to a unique path (e.g., assiduidade_{year}.ndjson) - Use an atomic write (write to tmp then os.replace) - After all tasks complete: - Merge the per-year NDJSONs into a single file in a downstream task or in the flow - Create the single aggregate artifact there Atomic writer (per task)
Copy code
import os
from pathlib import Path
import json

def save_ndjson(records: list[dict], dest_path: str | Path) -> str:
    dest_path = Path(dest_path)
    dest_path.parent.mkdir(parents=True, exist_ok=True)
    tmp_path = dest_path.with_suffix(dest_path.suffix + ".tmp")

    with open(tmp_path, "w", encoding="utf-8") as f:
        for rec in records:
            f.write(json.dumps(rec, ensure_ascii=False) + "\n")

    # Atomic replace on the same filesystem
    os.replace(tmp_path, dest_path)
    return str(dest_path)
Use unique filenames per task
Copy code
# inside extract_assiduidade_deputados(...)
dest = Path(out_dir) / f"assiduidade_{legislatura_year}.ndjson"
dest_path = save_ndjson(json_results, dest)
return dest_path
Merge step (downstream)
Copy code
import os, shutil
from pathlib import Path

def merge_ndjson(inputs: list[str | Path], dest: str | Path) -> str:
    dest = Path(dest)
    dest.parent.mkdir(parents=True, exist_ok=True)
    tmp = dest.with_suffix(dest.suffix + ".tmp")

    with open(tmp, "w", encoding="utf-8") as out:
        for p in inputs:
            p = Path(p)
            if not p.exists():
                continue
            with open(p, "r", encoding="utf-8") as f:
                shutil.copyfileobj(f, out)

    os.replace(tmp, dest)
    return str(dest)
In your flow
Copy code
# submit one task per year (what you’re already doing)
assiduidade_fs = [
    extract_assiduidade_deputados.submit(cast(Any, deputados_f), ano)
    for ano in anos_passados
]

# later, merge all NDJSONs deterministically
from prefect.futures import resolve_futures_to_results
paths = resolve_futures_to_results(assiduidade_fs)
final_path = merge_ndjson(paths, Path("data/camara") / "assiduidade.ndjson")

# create a single aggregate artifact here if desired
from prefect.artifacts import create_table_artifact
# build an aggregated table across years, then:
# create_table_artifact(key="assiduidade", table=agg_rows, description="...")
If you really must write to a single file from multiple tasks - Open in append mode with a file lock. This is still risky across processes and can interleave lines if not locked properly. A lock file (e.g., using the filelock package) can coordinate, but the aggregate step is cleaner:
Copy code
from filelock import FileLock

def append_ndjson(records: list[dict], dest_path: str | Path):
    dest_path = Path(dest_path)
    dest_path.parent.mkdir(parents=True, exist_ok=True)
    lock = FileLock(str(dest_path) + ".lock")
    with lock:
        with open(dest_path, "a", encoding="utf-8") as f:
            for rec in records:
                f.write(json.dumps(rec, ensure_ascii=False) + "\n")
About artifacts - You’re calling acreate_table_artifact(key="assiduidade") inside each per-year task. That will publish multiple versions with the same key; the latest one wins in the UI. - Options: - Key per year (e.g., assiduidade-2022, assiduidade-2023), or - Move table creation to the merge/aggregate step and publish a single combined table once. This will keep your writes safe and your artifacts consistent while tasks run in parallel. Want me to refactor your pipeline snippet to include the merge step and a single aggregate artifact?
g
@Marvin import os from pathlib import Path import json def save_ndjson(records: list[dict], dest_path: str | Path) -> str: dest_path = Path(dest_path) dest_path.parent.mkdir(parents=True, exist_ok=True) tmp_path = dest_path.with_suffix(dest_path.suffix + ".tmp") with open(tmp_path, "w", encoding="utf-8") as f: for rec in records: f.write(json.dumps(rec, ensure_ascii=False) + "\n") # Atomic replace on the same filesystem os.replace(tmp_path, dest_path) return str(dest_path) This modification of the function. Explain to me Why it has a tmp_path and atomic replace. It wont affect other places where it uses the save_ndjson?
m
✅ thought for 20.9 seconds
Great question. The tmp file + atomic replace pattern is about safety and consistency when writing files. Why write to tmp first, then replace? - Prevents partial files: If the process crashes or is interrupted mid-write, the destination file is never left half-written. Either the old file remains or the fully-written new file appears. - Readers never see in-progress writes: Other code that opens the destination path will either get the previous complete file or, after the replace, the new complete file. No torn/truncated content. - Atomic swap: On the same filesystem/volume, os.replace performs an atomic rename that replaces the target. This gives “all-or-nothing” behavior at the destination path. Will it affect other places that use save_ndjson? - Function signature is unchanged — it’s a drop-in replacement. - The only observable behavior change is timing: previously the file existed during the write; now it “appears” only when fully written. If anything was tailing/reading while you wrote line-by-line, it will now only see the file after completion. For most pipelines, that’s an improvement. - It does not fix concurrent writers to the same destination. If two tasks write the same dest concurrently, you still have last-writer-wins. The pattern prevents partial/truncated files but does not serialize concurrent writes. One refinement to avoid tmp-file collisions If there’s any chance two writers could target the same dest concurrently, use a unique tmp filename so they don’t both write to the same .tmp file before the replace:
Copy code
import os, json
from pathlib import Path
from uuid import uuid4

def save_ndjson(records: list[dict], dest_path: str | Path) -> str:
    dest_path = Path(dest_path)
    dest_path.parent.mkdir(parents=True, exist_ok=True)
    # unique tmp file in the same directory to keep replace atomic
    tmp_path = dest_path.with_suffix(dest_path.suffix + f".{os.getpid()}.{uuid4().hex}.tmp")

    try:
        with open(tmp_path, "w", encoding="utf-8") as f:
            for rec in records:
                f.write(json.dumps(rec, ensure_ascii=False) + "\n")
        os.replace(tmp_path, dest_path)  # atomic swap on same filesystem
    finally:
        # best-effort cleanup if something went wrong before replace
        if tmp_path.exists():
            try:
                tmp_path.unlink()
            except OSError:
                pass

    return str(dest_path)
Notes - Keep the tmp file on the same filesystem/drive as dest; atomic replace semantics depend on that. - On Windows/NTFS, os.replace is atomic, but replacing a file that another process has open in exclusive mode can raise an error. Generally fine for standard usage. - For truly concurrent append-by-many-writers, use an append function with a file lock (e.g., filelock). The aggregate-then-merge pattern you’re using is safer and simpler. So, no adverse impact for typical usage — it just makes writes safer. If you have any readers that depended on reading lines while the file was being written, they’ll now need to wait until the write completes.
g
@Marvin from prefect import flow, get_run_logger from prefect.task_runners import ThreadPoolTaskRunner from prefect.futures import resolve_futures_to_results from datetime import date, datetime from typing import Any, cast from pathlib import Path from tasks.tse import TSE_ENDPOINTS, extract_tse from tasks.camara.legislatura import extract_legislatura from tasks.camara.deputados import extract_deputados from tasks.camara.frentes import extract_frentes from tasks.camara.frentes_membros import extract_frentes_membros from tasks.camara.assiduidade import extract_assiduidade_deputados from utils.io import merge_ndjson from config.loader import load_config APP_SETTINGS = load_config() # IMPORTAR TASKS TSE, CONGRESSO, SENADO ETC... @flow( task_runner=ThreadPoolTaskRunner(max_workers=APP_SETTINGS.FLOW.MAX_RUNNERS), # type: ignore log_prints=True ) async def pipeline( date: date = datetime.now().date(), refresh_cache: bool = False ): logger = get_run_logger() logger.info("Iniciando pipeline") # TSE: ~30 endpoints em paralelo tse_fs = [ cast(Any, extract_tse) .with_options(refresh_cache=refresh_cache) .submit(name, url) for name, url in TSE_ENDPOINTS.items() ] # CONGRESSO legislatura = extract_legislatura(date) deputados_f = extract_deputados.submit(legislatura) anos_passados = legislatura.get("dados", [])[0].get("anosPassados", []) ### ASSIDUIDADE assiduidade_fs = [ extract_assiduidade_deputados.submit(cast(Any, deputados_f), ano) for ano in anos_passados ] paths = resolve_futures_to_results(assiduidade_fs) final_path = merge_ndjson(paths, Path("data/camara") / "assiduidade.ndjson") ###### frentes_f = extract_frentes.submit(legislatura) frentes_membros_f = extract_frentes_membros.submit(cast(Any, frentes_f)) return resolve_futures_to_results({ "tse": tse_fs, "congresso_deputados": deputados_f, "congresso_assiduidade": assiduidade_fs, "congresso_frentes": frentes_f, "congresso_frentes_membros": frentes_membros_f, }) if name == "__main__": pipeline.serve( # type: ignore name="deploy-1" ) I have applied the solution. This is how my flow is configured. Analyze it
m
✅ thought for 37.3 seconds
Overall, you’re very close. A few important fixes and optimizations: 1) Fix the cache_key_fn signature - cache_key_fn receives (context, params_dict). In your task you used lambda _, year: … which treats the entire params dict as year and produced invalid cache keys earlier. - Use a params dict and return a filesystem-safe key. Example
Copy code
from datetime import date, timedelta

def assiduidade_cache_key(_, p) -> str | None:
    year = int(p["legislatura_year"])
    if year == date.today().year:
        return None  # skip cache for current year
    return f"assiduidade-{year}"  # short, filesystem-safe key

@task(
    retries=APP_SETTINGS.CAMARA.RETRIES,
    retry_delay_seconds=APP_SETTINGS.CAMARA.RETRY_DELAY,
    timeout_seconds=1800,
    cache_key_fn=assiduidade_cache_key,
    cache_expiration=timedelta(days=180),
)
async def extract_assiduidade_deputados(...):
    ...
2) Write a unique NDJSON per year inside the task - Right now each per-year task writes to the same "assiduidade.ndjson", so the last writer wins. Return a per-year file and merge later (you already have merge_ndjson downstream). Inside the task
Copy code
dest = Path(out_dir) / f"assiduidade_{legislatura_year}.ndjson"
dest_path = save_ndjson(json_results, dest)
return dest_path
3) Don’t block the flow before submitting other branches - You call resolve_futures_to_results(assiduidade_fs) before submitting frentes/frentes_membros. That pauses scheduling of those tasks until assiduidade is fully done. - Submit frentes/frentes_membros first, then resolve assiduidade when you actually need the paths (or make merge a task). Example reordering
Copy code
# submit assiduidade tasks
assiduidade_fs = [
    extract_assiduidade_deputados.submit(cast(Any, deputados_f), ano)
    for ano in anos_passados
]

# submit independent branch BEFORE waiting
frentes_f = extract_frentes.submit(legislatura)
frentes_membros_f = extract_frentes_membros.submit(cast(Any, frentes_f))

# now resolve and merge
paths = resolve_futures_to_results(assiduidade_fs)
final_path = merge_ndjson(paths, Path("data/camara") / "assiduidade.ndjson")
Optional: make merge_ndjson a @task so it shows up in the UI and runs on the task runner:
Copy code
@task
def merge_ndjson_task(paths: list[str], dest: str | Path) -> str:
    return merge_ndjson(paths, dest)

merged_f = merge_ndjson_task.submit(assiduidade_fs, Path("data/camara") / "assiduidade.ndjson")
4) Artifacts - If you publish a table artifact per-year inside the task, use a per-year key to avoid overwriting versions immediately:
Copy code
await acreate_table_artifact(
    key=f"assiduidade-{legislatura_year}",
    table=artifact_data,
    description=f"Assiduidade de deputados ({legislatura_year})",
)
- If you want one combined table, move table creation to the merge/aggregate step instead. 5) Safer anos_passados extraction - Your pipeline uses legislatura.get("dados", [])[0] which assumes a list. If dados is a dict, this will raise.
Copy code
dados = (legislatura.get("dados") or {})
anos_passados = dados.get("anosPassados", [])
6) Optional: force-refresh current-year when needed - You can bypass cache per-run by setting refresh_cache at submit:
Copy code
extract_assiduidade_deputados.with_options(refresh_cache=refresh_cache).submit(...)
Quick checklist - Unique per-year filenames in the task: yes - Cache key uses params dict and is filesystem-safe: yes - Submit independent branches before resolving futures: yes - Table artifacts either per-year key or created once in aggregate: choose one - Safer anos_passados access: yes References - Caching: Caching concepts - Artifacts: Artifacts how-to
g
@Marvin I did it all but i got the error: FileNotFoundError: [WinError 2] O sistema nĂŁo pode encontrar o arquivo especificado: 'data\\camara\\assiduidade_2025.ndjson.tmp' -> 'data\\camara\\assiduidade_2025.ndjson'
m
✅ thought for 43.4 seconds
Thanks for the details. That Windows FileNotFoundError on os.replace usually means the tmp file didn’t exist at the moment of the replace. The most common cause in parallel pipelines is a tmp filename collision: two writers targeting the same destination path use the same tmp name (e.g., .ndjson.tmp). One finishes first and moves the tmp into place, removing the tmp path; the other then tries to replace a tmp that’s no longer there. Why this can happen in your case - You’re running multiple tasks in parallel. If more than one task ends up writing to the same final file (e.g., same year) at the same time (due to duplicate years, retries, or concurrent flows), they’ll share the same tmp filename when using .with_suffix(".tmp"). - Even with per-year outputs, duplicates or concurrent runs can cause two writers for the same year. Fix 1 — Use a unique tmp filename per writer This prevents tmp collisions while keeping the replace atomic.
Copy code
import os, json
from pathlib import Path
from uuid import uuid4

def save_ndjson(records: list[dict], dest_path: str | Path) -> str:
    dest_path = Path(dest_path)
    dest_path.parent.mkdir(parents=True, exist_ok=True)
    # unique tmp in same directory (same filesystem) to keep replace atomic
    tmp_path = dest_path.with_name(dest_path.name + f".{os.getpid()}.{uuid4().hex}.tmp")

    try:
        with open(tmp_path, "w", encoding="utf-8") as f:
            for rec in records:
                f.write(json.dumps(rec, ensure_ascii=False) + "\n")
        os.replace(tmp_path, dest_path)  # atomic swap on same volume
    finally:
        # best-effort cleanup if something went wrong before replace
        if tmp_path.exists():
            try:
                tmp_path.unlink()
            except OSError:
                pass

    return str(dest_path)
Fix 2 — Ensure you don’t spawn duplicate writers - Deduplicate years before submitting tasks: -
anos_passados = sorted(set(anos_passados))
- If you run multiple flow runs concurrently (or task retries can overlap), make the output path unique per run to avoid cross-run conflicts, e.g., include flow run id or a timestamp: -
assiduidade_{year}_{flow_run_id}.ndjson
- Then merge downstream into a canonical file. Fix 3 — Keep one writer per final file - You’re already writing per-year and merging later — that’s good. - Double-check that each per-year task writes to
assiduidade_{year}.ndjson
(not all to the same filename). - If you ever need multiple writers to append to a single file, use a file lock and append mode instead, but the aggregate-then-merge pattern is simpler and safer. Optional diagnostics - Log the tmp_path and dest before the replace; if the error repeats, check for duplicate submissions for the same year in logs. - After the write block, add
assert Path(tmp_path).exists()
to catch early if the tmp is missing. This change is drop-in: it won’t affect other callers except that files will “appear” only when fully written (safer) and tmp files are unique per writer.
g
@Marvin Since ive applied your changes to save_ndjson im getting erros where it usess: def save_ndjson(records: list[dict], dest_path: str | Path) -> str: dest_path = Path(dest_path) dest_path.parent.mkdir(parents=True, exist_ok=True) tmp_path = dest_path.with_suffix(dest_path.suffix + ".tmp") with open(dest_path, "w", encoding="utf-8") as f: for rec in records: f.write(json.dumps(rec, ensure_ascii=False) + "\n") os.replace(tmp_path, dest_path) return str(dest_path) FileNotFoundError: [WinError 2] O sistema nĂŁo pode encontrar o arquivo especificado: 'data\\camara\\frentes.ndjson.tmp' -> 'data\\camara\\frentes.ndjson'
m
✅ thought for 9.9 seconds
You’re seeing that error because your function creates a tmp_path but writes to dest_path, then tries to replace tmp_path → dest_path. Since tmp_path was never created, os.replace raises FileNotFoundError. Fix: write to tmp_path, then replace it into dest_path. Minimal atomic version
Copy code
import os, json
from pathlib import Path

def save_ndjson(records: list[dict], dest_path: str | Path) -> str:
    dest_path = Path(dest_path)
    dest_path.parent.mkdir(parents=True, exist_ok=True)
    tmp_path = dest_path.with_suffix(dest_path.suffix + ".tmp")

    with open(tmp_path, "w", encoding="utf-8") as f:
        for rec in records:
            f.write(json.dumps(rec, ensure_ascii=False) + "\n")

    os.replace(tmp_path, dest_path)  # atomic on same filesystem
    return str(dest_path)
More robust (avoid tmp collisions when multiple writers target the same dest)
Copy code
import os, json
from pathlib import Path
from uuid import uuid4

def save_ndjson(records: list[dict], dest_path: str | Path) -> str:
    dest_path = Path(dest_path)
    dest_path.parent.mkdir(parents=True, exist_ok=True)
    tmp_path = dest_path.with_name(dest_path.name + f".{os.getpid()}.{uuid4().hex}.tmp")

    try:
        with open(tmp_path, "w", encoding="utf-8") as f:
            for rec in records:
                f.write(json.dumps(rec, ensure_ascii=False) + "\n")
        os.replace(tmp_path, dest_path)
    finally:
        if tmp_path.exists():
            try:
                tmp_path.unlink()
            except OSError:
                pass
    return str(dest_path)
Notes - Keep tmp in the same directory as dest so os.replace stays atomic. - This pattern doesn’t serialize concurrent writers; it just prevents partial files. If multiple tasks write the same dest concurrently, last-writer-wins. Use unique filenames per task (e.g., per-year) and merge later, as you’re doing. - The frentes.ndjson path in your error suggests another task may still be writing to a single shared filename; ensure each parallel task writes a unique file or you coordinate writes.
g
@Marvin from prefect import flow, get_run_logger from prefect.task_runners import ThreadPoolTaskRunner from prefect.futures import resolve_futures_to_results from datetime import date, datetime from typing import Any, cast from pathlib import Path from tasks.tse import TSE_ENDPOINTS, extract_tse from tasks.camara.legislatura import extract_legislatura from tasks.camara.deputados import extract_deputados from tasks.camara.frentes import extract_frentes from tasks.camara.frentes_membros import extract_frentes_membros from tasks.camara.assiduidade import extract_assiduidade_deputados from utils.io import merge_ndjson from config.loader import load_config APP_SETTINGS = load_config() # IMPORTAR TASKS TSE, CONGRESSO, SENADO ETC... @flow( task_runner=ThreadPoolTaskRunner(max_workers=APP_SETTINGS.FLOW.MAX_RUNNERS), # type: ignore log_prints=True ) async def pipeline( date: date = datetime.now().date(), refresh_cache: bool = False ): logger = get_run_logger() logger.info("Iniciando pipeline") # TSE: ~30 endpoints em paralelo tse_fs = [ cast(Any, extract_tse) .with_options(refresh_cache=refresh_cache) .submit(name, url) for name, url in TSE_ENDPOINTS.items() ] # CONGRESSO legislatura = extract_legislatura(date) deputados_f = extract_deputados.submit(legislatura) anos_passados = legislatura.get("dados", [])[0].get("anosPassados", []) assiduidade_fs = [ extract_assiduidade_deputados.with_options(refresh_cache=refresh_cache).submit(cast(Any, deputados_f), ano) for ano in anos_passados ] frentes_f = extract_frentes.submit(legislatura) frentes_membros_f = extract_frentes_membros.submit(cast(Any, frentes_f)) # ASSIDUIDADE # As funçþes de Assiduidade (uma por ano) baixa em paralelo em relação às outras tasks # Por isso seus arquivos precisam ser juntados em um único NDJson (serå) # Abaixo o código feito após todos os outros processos para não travar o flow paths = resolve_futures_to_results(assiduidade_fs) final_path = merge_ndjson(paths, Path("data/camara") / "assiduidade.ndjson") return resolve_futures_to_results({ "tse": tse_fs, "congresso_deputados": deputados_f, "congresso_assiduidade": assiduidade_fs, "congresso_frentes": frentes_f, "congresso_frentes_membros": frentes_membros_f, }) if name == "__main__": pipeline.serve( # type: ignore name="deploy-1" ) This is my pipeline.py. Now i got the task extract_detalhes_deputados(). It depends on the deputados_ids but it can only be executed after membros_frentes because they both use the same api and it will reach the request llimit
m
✅ thought for 351.0 seconds
Got it — you want
extract_detalhes_deputados()
to use the
deputados
IDs but only start after
frentes_membros
completes because both hit the same API and you’ll otherwise rate-limit. You’ve got two good options in Prefect 3.x: Option A — Explicit ordering in the flow (simple and strict) Wait for
frentes_membros
to finish before submitting
extract_detalhes_deputados
tasks. Other work you’ve already submitted can continue in parallel; only the submission of
detalhes
waits. Here’s how you can patch your flow:
Copy code
from prefect import flow, get_run_logger
from prefect.task_runners import ThreadPoolTaskRunner
from prefect.futures import resolve_futures_to_results
from datetime import date, datetime
from typing import Any, cast
from pathlib import Path

from tasks.tse import TSE_ENDPOINTS, extract_tse
from tasks.camara.legislatura import extract_legislatura
from tasks.camara.deputados import extract_deputados
from tasks.camara.frentes import extract_frentes
from tasks.camara.frentes_membros import extract_frentes_membros
from tasks.camara.assiduidade import extract_assiduidade_deputados
from tasks.camara.detalhes_deputados import extract_detalhes_deputados  # NEW

from <http://utils.io|utils.io> import merge_ndjson
from config.loader import load_config

APP_SETTINGS = load_config()

@flow(
    task_runner=ThreadPoolTaskRunner(max_workers=APP_SETTINGS.FLOW.MAX_RUNNERS),  # type: ignore
    log_prints=True
)
async def pipeline(
    date: date = datetime.now().date(),
    refresh_cache: bool = False
):
    logger = get_run_logger()
    <http://logger.info|logger.info>("Iniciando pipeline")

    # TSE: ~30 endpoints em paralelo
    tse_fs = [
        cast(Any, extract_tse)
        .with_options(refresh_cache=refresh_cache)
        .submit(name, url)
        for name, url in TSE_ENDPOINTS.items()
    ]

    # CONGRESSO
    legislatura = extract_legislatura(date)
    deputados_f = extract_deputados.submit(legislatura)

    anos_passados = legislatura.get("dados", [])[0].get("anosPassados", [])
    assiduidade_fs = [
        extract_assiduidade_deputados.with_options(refresh_cache=refresh_cache).submit(cast(Any, deputados_f), ano)
        for ano in anos_passados
    ]

    frentes_f = extract_frentes.submit(legislatura)
    frentes_membros_f = extract_frentes_membros.submit(cast(Any, frentes_f))

    # WAIT HERE so detalhes only starts after membros_frentes is complete
    resolve_futures_to_results([frentes_membros_f])

    # Resolve deputados to get IDs
    deputados = resolve_futures_to_results([deputados_f])[0]
    deputado_ids = [d.get("id") for d in deputados.get("dados", []) if d.get("id") is not None]

    # Now submit detalhes; they can run in parallel with everything else that’s still running
    detalhes_fs = [
        extract_detalhes_deputados.with_options(refresh_cache=refresh_cache).submit(dep_id)
        for dep_id in deputado_ids
    ]

    # ASSIDUIDADE: merge NDJSONs after downloads finish
    paths = resolve_futures_to_results(assiduidade_fs)
    final_path = merge_ndjson(paths, Path("data/camara") / "assiduidade.ndjson")

    return resolve_futures_to_results({
        "tse": tse_fs,
        "congresso_deputados": deputados_f,
        "congresso_assiduidade": assiduidade_fs,
        "congresso_frentes": frentes_f,
        "congresso_frentes_membros": frentes_membros_f,
        "congresso_detalhes_deputados": detalhes_fs,
    })

if __name__ == "__main__":
    pipeline.serve(  # type: ignore
        name="deploy-1"
    )
Option B — Concurrency limits by tag (no strict wait, but prevents overlap) If you’d rather keep everything “fire-and-forget” and let Prefect orchestrate, tag both
frentes_membros
and
detalhes_deputados
tasks with the same tag (e.g.,
camara-api
), then create a concurrency limit of 1 for that tag. Prefect will allow only one task with that tag to run at a time; the other will sit in AwaitingConcurrencySlot and only start after the first completes. - Tag the submissions: ```
frentes_membros_f = extract_frentes_membros.with_options(tags={"camara-api"}).submit(cast(Any, frentes_f)) detalhes_fs = [ extract_detalhes_deputados.with_options(tags={"camara-api"}, refresh_cache=refresh_cache).submit(dep_id) for dep_id in deputado_ids ]
Copy code
- Create the limit once (requires Prefect server/Cloud):
prefect concurrency-limit create camara-api 1
Copy code
You can inspect/adjust later with:
prefect concurrency-limit inspect camara-api prefect concurrency-limit ls prefect concurrency-limit reset camara-api prefect concurrency-limit delete camara-api
Copy code
When to use which
- If you want “detalhes” strictly after “membros_frentes,” use Option A.
- If you just want to ensure they never overlap (but don’t care about exact ordering), use Option B. It also scales if other flows hit the same API.

Optional: request-level rate limiting inside tasks
If each task makes many HTTP requests internally, add a request-rate limiter so you don’t exceed the API’s per-second/minute thresholds:

- For async tasks:
from prefect.concurrency.asyncio import rate_limit @task async def extract_frentes_membros(...): # e.g., allow 5 requests per second across all flows async with rate_limit("camara-api-requests", rate=5, per=1): ...
Copy code
- For sync tasks:
from prefect.concurrency.sync import rate_limit @task def extract_frentes_membros(...): with rate_limit("camara-api-requests", rate=5, per=1): ... ``` Quick check: do you prefer strict sequencing (Option A) or preventing overlap via a concurrency limit (Option B)? I can help you wire up whichever you choose.
g
@Marvin What are the benefits of explicity ordering the tasks vs tagging and letting prefect orquestrate
m
✅ thought for 26.5 seconds
Great question. Here’s a concise comparison. Explicit ordering (await/barrier in the flow) - Pros - Deterministic: guarantees “run B only after A finishes” within the same flow run. - Simple mental model: a clear barrier that’s easy to reason about and debug. - Works offline/in local runs without Prefect Server/Cloud. - Zero orchestration queueing; no AwaitingConcurrencySlot state. - Cons - Coarse-grained: you may block useful work; loses parallelism even if the API would allow some. - Only scopes to this flow run; does not protect the API from other flows or teams. - If you submit thousands of “B” tasks after the barrier, you can still spike the API unless you also throttle inside tasks. Tag-based concurrency limits (let Prefect orchestrate) - Pros - Global throttle: enforces limits across flows, deployments, and workers—great for shared APIs. - Adjustable at runtime (CLI/UI) without redeploying code; you can tune limit=1, 2, 5, etc. - Preserves parallelism up to your limit; automatic backpressure with AwaitingConcurrencySlot. - Plays nicely with retries; tasks will re-enter the queue and respect the limit. - Cons - Not a strict barrier: with limit=1 you prevent overlap but do not guarantee “all A before any B”. - Requires Prefect Server/Cloud; not available in fully local/offline engine-only runs. - Ordering is not strictly FIFO; the scheduler decides which queued task gets the next slot. When to use which - Use explicit ordering if you need strict “after all frentes_membros is complete, then start detalhes_deputados” within this flow. - Use concurrency limits if your goal is “never overload this API across the whole platform,” and you’re okay with the scheduler interleaving tasks (or you want tunable parallelism). Common hybrid pattern - Keep a small concurrency limit on the shared API tag (e.g., 1–3) to protect the API globally, and still add a barrier before submitting detalhes_deputados if you truly need “A fully completes before B starts” in this flow. - If the API enforces per-second/min quotas, combine the above with request-level rate limiting inside the tasks to smooth bursts rather than fully serializing whole phases.
g
@Marvin from prefect import flow, get_run_logger from prefect.task_runners import ThreadPoolTaskRunner from prefect.futures import resolve_futures_to_results from datetime import date, datetime from typing import Any, cast from pathlib import Path from tasks.tse import TSE_ENDPOINTS, extract_tse from tasks import camara from utils.io import merge_ndjson from config.loader import load_config APP_SETTINGS = load_config() # IMPORTAR TASKS TSE, CONGRESSO, SENADO ETC... @flow( task_runner=ThreadPoolTaskRunner(max_workers=APP_SETTINGS.FLOW.MAX_RUNNERS), # type: ignore log_prints=True ) async def pipeline( date: date = datetime.now().date(), refresh_cache: bool = False ): logger = get_run_logger() logger.info("Iniciando pipeline") # TSE: ~30 endpoints em paralelo tse_fs = [ cast(Any, extract_tse) .with_options(refresh_cache=refresh_cache) .submit(name, url) for name, url in TSE_ENDPOINTS.items() ] # CONGRESSO legislatura = camara.extract_legislatura(date) deputados_f = camara.extract_deputados.submit(legislatura) anos_passados = legislatura.get("dados", [])[0].get("anosPassados", []) assiduidade_fs = [ camara.extract_assiduidade_deputados.with_options(refresh_cache=refresh_cache).submit(cast(Any, deputados_f), ano) for ano in anos_passados ] frentes_f = camara.extract_frentes.submit(legislatura) frentes_membros_f = camara.extract_frentes_membros.submit(cast(Any, frentes_f)) resolve_futures_to_results([frentes_membros_f]) detalhes_deputados_fs = camara.extract_detalhes_deputados(deputados_f) # ASSIDUIDADE # As funçþes de Assiduidade (uma por ano) baixa em paralelo em relação às outras tasks # Por isso seus arquivos precisam ser juntados em um único NDJson (serå) # Abaixo o código feito após todos os outros processos para não travar o flow paths = resolve_futures_to_results(assiduidade_fs) final_path = merge_ndjson(paths, Path("data/camara") / "assiduidade.ndjson") return resolve_futures_to_results({ "tse": tse_fs, "congresso_deputados": deputados_f, "congresso_assiduidade": assiduidade_fs, "congresso_frentes": frentes_f, "congresso_frentes_membros": frentes_membros_f, "congresso_detalhes_deputados": detalhes_deputados_fs }) if name == "__main__": pipeline.serve( # type: ignore name="deploy-1" ) I did this but extract detalhes deputados its not being executed. Just for context, Assiduidade can run in parallel to any other execution in here, just need the id from deputados.
m
✅ thought for 47.7 seconds
Thanks for sharing the snippet — I see the issue. Right now you’re calling the task directly:
Copy code
detalhes_deputados_fs = camara.extract_detalhes_deputados(deputados_f)
That does two problematic things: - It does not submit anything to the task runner, so you won’t see concurrent task runs for each deputado. If
extract_detalhes_deputados
is a task, you typically want
.submit(...)
to schedule it. - You’re passing a PrefectFuture (
deputados_f
) instead of the resolved list/ids. While Prefect can infer upstreams when you use
.submit
, passing a future into a direct call can lead to confusing behavior and no visible task runs. Also, note:
with_options(refresh_cache=refresh_cache)
does not pass that argument to your function —
with_options
only sets Prefect task options (name, tags, retries, etc.). If your task expects
refresh_cache
, pass it to
.submit(..., refresh_cache=refresh_cache)
. Two fixed patterns depending on how
extract_detalhes_deputados
is defined: A) If extract_detalhes_deputados processes ONE deputado per call (recommended for parallelism)
Copy code
# Wait so detalhes only starts after membros_frentes finishes
resolve_futures_to_results([frentes_membros_f])

# Resolve deputados and build IDs
deputados = resolve_futures_to_results([deputados_f])[0]
deputado_ids = [d.get("id") for d in deputados.get("dados", []) if d.get("id") is not None]

# Submit one task per deputado (runs in parallel up to your task_runner limit)
detalhes_deputados_fs = [
    camara.extract_detalhes_deputados
    .with_options(tags={"camara-api"})  # optional: tag for concurrency limiting
    .submit(dep_id, refresh_cache=refresh_cache)
    for dep_id in deputado_ids
]
B) If extract_detalhes_deputados expects the WHOLE list and does its own internal fan-out
Copy code
# Wait so detalhes only starts after membros_frentes finishes
resolve_futures_to_results([frentes_membros_f])

# Resolve deputados and pass the list (not the future)
deputados = resolve_futures_to_results([deputados_f])[0]

# Submit a single task that handles all IDs internally
detalhes_deputados_f = camara.extract_detalhes_deputados.submit(
    deputados, 
    refresh_cache=refresh_cache
)
Optional safety: prevent API overlap globally - Tag both
extract_frentes_membros
and
extract_detalhes_deputados
with
camara-api
, then set a concurrency limit of 1 (or whatever your API can handle). That guarantees they don’t run at the same time even across different flows/deployments.
Copy code
frentes_membros_f = camara.extract_frentes_membros.with_options(tags={"camara-api"}).submit(cast(Any, frentes_f))
# detalhes tasks above already show tags={"camara-api"}

# One-time setup in your environment:
prefect concurrency-limit create camara-api 1
Can you confirm: - Is
camara.extract_detalhes_deputados
a @task or a @flow? - Does it accept a single ID or a list of IDs? Based on that I can give you the exact final snippet to drop in.
g
@Marvin from pathlib import Path from prefect import task, get_run_logger from prefect.artifacts import ( acreate_progress_artifact, aupdate_progress_artifact, acreate_table_artifact ) from typing import cast from utils.io import fetch_json_many_async, save_ndjson from config.loader import load_config APP_SETTINGS = load_config() def urls_discursos(deputados_ids: list[int], legislatura_id: int) -> list[str]: return [f"{APP_SETTINGS.CAMARA.REST_BASE_URL}deputados/{id}/discursos?idLegislatura={legislatura_id}&itens=1000" for id in deputados_ids] @task( retries=APP_SETTINGS.CAMARA.RETRIES, retry_delay_seconds=APP_SETTINGS.CAMARA.RETRY_DELAY, timeout_seconds=APP_SETTINGS.CAMARA.TIMEOUT ) async def extract_discursos_deputados(deputados_ids: list[int], legislatura_id: int, out_dir: str | Path = "data/camara") -> str: logger = get_run_logger() progress_id = await acreate_progress_artifact( progress=0.0, description="Progresso do download de Discursos de Deputados" ) urls = urls_discursos(deputados_ids, legislatura_id) logger.info(f"Câmara: buscando discursos de {len(urls)} deputados") jsons = await fetch_json_many_async( urls=urls, concurrency=APP_SETTINGS.CAMARA.CONCURRENCY, timeout=APP_SETTINGS.CAMARA.TIMEOUT, follow_pagination=True, progress_artifact_id=progress_id ) await aupdate_progress_artifact( artifact_id=progress_id, progress=100.0, description="Downloads concluídos" ) # Gerando artefato para validação dos dados artifact_data = [] for i, json in enumerate(jsons): json = cast(dict, json) deputado = json.get("dados", []) # type: ignore artifact_data.append({ "index": i, "id": deputado.get("id"), "nome": deputado.get("nome"), "situacao": deputado.get("ultimoStatus", {}).get("situacao"), "condicao_eleitoral": deputado.get("ultimoStatus", {}).get("condicaoEleitoral") }) await acreate_table_artifact( key="discursos_deputados", table=artifact_data, description="Detalhes de deputados" ) dest = Path(out_dir) / "detalhes_deputados.ndjson" return save_ndjson(cast(list[dict], jsons), dest) This is one of my tasks, It recieves a list of ints to build the urls. But it is a third party api and for example im having problems with this endpoint trying to search for some new id. But this breakes the app because it returns 404. What are my options to deal with this kind of problem
m
✅ thought for 326.6 seconds
You’ve got a few good options here to keep your flow resilient when some IDs return 404s. In Prefect 3.x, the general pattern is: treat 404s as “expected misses”, record them, and keep the rest of the run moving. Below are practical approaches you can mix and match. Option A — Catch-and-continue inside your task (keep your current structure) - Treat 404 as a non-fatal outcome, return None (or a small error record) for that ID. - If your
fetch_json_many_async
is yours, update it (or wrap it) to use
asyncio.gather(..., return_exceptions=True)
so a few 404s don’t fail the whole batch. - Filter out Nones before saving, and create an artifact listing any IDs that were missing. Example (sketch, showing the idea):
Copy code
@task(...)
async def extract_discursos_deputados(deputados_ids: list[int], legislatura_id: int, out_dir: str | Path = "data/camara") -> str:
    logger = get_run_logger()
    progress_id = await acreate_progress_artifact(progress=0.0, description="Progresso do download de Discursos de Deputados")

    urls = urls_discursos(deputados_ids, legislatura_id)
    <http://logger.info|logger.info>(f"Câmara: buscando discursos de {len(urls)} deputados")

    # If fetch_json_many_async can't be configured to not raise, wrap per-URL:
    import asyncio, httpx

    async def fetch_one(i: int, url: str):
        try:
            # your client here; or adapt to your utils
            return await fetch_json_many_async(urls=[url], concurrency=1, timeout=APP_SETTINGS.CAMARA.TIMEOUT, follow_pagination=True)
        except httpx.HTTPStatusError as e:
            if e.response.status_code == 404:
                logger.warning(f"Deputado {deputados_ids[i]} não encontrado (404) – ignorando")
                return None
            raise

    results = await asyncio.gather(*(fetch_one(i, u) for i, u in enumerate(urls)), return_exceptions=True)

    ok, missing, hard_failures = [], [], []
    for i, r in enumerate(results):
        if isinstance(r, Exception):
            hard_failures.append({"id": deputados_ids[i], "error": str(r)})
        elif r is None:
            missing.append({"id": deputados_ids[i], "status": 404})
        else:
            # fetch_json_many_async([...]) returns a list; take first item
            ok.append(r[0] if isinstance(r, list) else r)

    await aupdate_progress_artifact(artifact_id=progress_id, progress=100.0, description="Downloads concluĂ­dos")

    # Emit an artifact summarizing misses/failures
    if missing or hard_failures:
        await acreate_table_artifact(
            key="discursos_deputados_misses",
            table=[*missing, *hard_failures],
            description="IDs ausentes (404) e falhas"
        )

    # Continue with only successful items
    artifact_data = []
    for i, json in enumerate(ok):
        json = cast(dict, json)
        deputado = json.get("dados", [])
        artifact_data.append({
            "index": i,
            "id": deputado.get("id"),
            "nome": deputado.get("nome"),
            "situacao": deputado.get("ultimoStatus", {}).get("situacao"),
            "condicao_eleitoral": deputado.get("ultimoStatus", {}).get("condicaoEleitoral")
        })

    await acreate_table_artifact(key="discursos_deputados", table=artifact_data, description="Detalhes de deputados")

    dest = Path(out_dir) / "detalhes_deputados.ndjson"
    return save_ndjson(cast(list[dict], ok), dest)
Option B — Split into a per-ID task and map it (preferred for clean partial-failure handling) - Create a small task that fetches a single ID and gracefully handles 404. - Call it in a flow with mapping so each ID is its own task run. Then use
allow_failure
to prevent failed items from failing the whole flow, and collect results. Sketch: ``` from prefect import flow, task, get_run_logger, unmapped, allow_failure @task( retries=APP_SETTINGS.CAMARA.RETRIES, retry_delay_seconds=APP_SETTINGS.CAMARA.RETRY_DELAY, # Only retry transient errors (not 404)
retry_condition_fn=lambda exc: getattr(getattr(exc, "response", None), "status_code", None) in {429, 500, 502, 503, 504}, ) async def fetch_discursos_for_id(deputado_id: int, legislatura_id: int): logger = get_run_logger() url = f"{APP_SETTINGS.CAMARA.REST_BASE_URL}deputados/{deputado_id}/discursos?idLegislatura={legislatura_id}&itens=1000" try: # use your client here; adapt to your utils as needed data_list = await fetch_json_many_async(urls=[url], concurrency=1, timeout=APP_SETTINGS.CAMARA.TIMEOUT, follow_pagination=True) return data_list[0] if data_list else None except Exception as e: # Treat 404 as a miss, re-raise others status = getattr(getattr(e, "response", None), "status_code", None) if status == 404: logger.warning(f"{deputado_id} não encontrado (404) – ignorando") return None raise @flow async def extract_discursos_deputados(deputados_ids: list[int], legislatura_id: int, out_dir: str | Path = "data/camara") -> str: logger = get_run_logger() progress_id = await acreate_progress_artifact(progress=0.0, description="Progresso do download de Discursos de Deputados") futures = fetch_discursos_for_id.map(deputados_ids, unmapped(legislatura_id)) safe = allow_failure(futures) results, missing, failed = [], [], [] for i, f in enumerate(safe): st = f.get_state() if st.is_completed(): val = f.result() if val is None: missing.append({"id": deputados_ids[i], "status": 404}) else: results.append(val) elif st.is_failed(): failed.append({"id": deputados_ids[i], "error": str(st)}) # summarize failure await aupdate_progress_artifact(artifact_id=progress_id, progress=100.0, description="Downloads concluídos") if missing or failed: await acreate_table_artifact(key="discursos_deputados_misses", table=[*missing, *failed], description="IDs ausentes e falhas") # proceed with good results only dest = Path(out_dir) / "detalhes_deputados.ndjson" return save_ndjson(cast(list[dict], results), dest) ``` Option C — Selective retries via retry_condition_fn - Keep your task retries for transient issues (429, 5xx), but don’t retry 404. - You can do this with
retry_condition_fn
as shown above. This avoids wasting time retrying “not found” resources. Option D — Pre-validate IDs (optional) - If the API supports a cheap existence check, you can filter IDs up front to avoid fetching known-missing ones. This can cut noise but adds an extra round trip. Option E — Record everything for observability - You’re already creating a progress artifact; adding a table artifact of “missing IDs” and “hard failures” makes triage easier. - You can also add a small markdown artifact summarizing counts (total, ok, 404, failed). Notes - Mapping requires calling tasks from a flow (don’t call tasks from inside other tasks). If you want to keep a single function, use Option A’s catch-and-continue approach inside one task. - If you control
fetch_json_many_async
, the simplest change is to make it return successes and exceptions per-URL, and never raise on 404. Then your Prefect task can just split results into ok/missing/failed and continue. Questions to help me tailor this: - Do you control
fetch_json_many_async
? If yes, we can make a small change there to return structured results for 404s and keep your current task shape. - Are you open to splitting into a flow with a mapped per-ID task? That’s the cleanest Prefect-native way to handle partial failures.
g
@Marvin tse_data_path = "data/tse" tse_fs = [ cast(Any, extract_tse) .with_options(refresh_cache=refresh_cache) .submit(name, url, tse_data_path) for name, url in TSE_ENDPOINTS.items() ] keep_only_files(path=tse_data_path, file_ext="csv") from pathlib import Path from typing import cast from uuid import UUID from prefect import task, get_run_logger from prefect.artifacts import create_progress_artifact from datetime import timedelta from utils.io import download_stream from utils.br_data import BR_STATES, ELECTIONS_YEARS from config.loader import load_config, CACHE_POLICY_MAP APP_SETTINGS = load_config() # MONTAR ENDPOINT REDES SOCIAIS POR ESTADO REDES_SOCIAIS_ENDPOINTS = { f"redes_sociais_{year}_{state}": f"{APP_SETTINGS.TSE.BASE_URL}consulta_cand/rede_social_candidato_{year}_{state}.zip" for state in BR_STATES for year in ELECTIONS_YEARS } TSE_ENDPOINTS = { "candidatos_2018": f"{APP_SETTINGS.TSE.BASE_URL}consulta_cand/consulta_cand_2018.zip", "candidatos_2022": f"{APP_SETTINGS.TSE.BASE_URL}consulta_cand/consulta_cand_2022.zip", "prestaca_contas_2018": f"{APP_SETTINGS.TSE.BASE_URL}prestacao_contas/prestacao_de_contas_eleitorais_candidatos_2018.zip", "prestacao_contas_2022": f"{APP_SETTINGS.TSE.BASE_URL}prestacao_contas/prestacao_de_contas_eleitorais_candidatos_2022.zip", "resultado_eleicao_2018": f"{APP_SETTINGS.TSE.BASE_URL}votacao_candidato_munzona/votacao_candidato_munzona_2018.zip", "resultado_eleicao_2022": f"{APP_SETTINGS.TSE.BASE_URL}votacao_candidato_munzona/votacao_candidato_munzona_2022.zip" } TSE_ENDPOINTS = TSE_ENDPOINTS | REDES_SOCIAIS_ENDPOINTS @task( task_run_name="extract_tse_{name}", retries=APP_SETTINGS.TSE.RETRIES, retry_delay_seconds=APP_SETTINGS.TSE.RETRY_DELAY, timeout_seconds=APP_SETTINGS.TSE.TIMEOUT, cache_policy=CACHE_POLICY_MAP[APP_SETTINGS.TSE.CACHE_POLICY], cache_expiration=timedelta(days=APP_SETTINGS.TSE.CACHE_EXPIRATION) ) def extract_tse(name: str, url: str, out_dir: str = "data/tse") -> str: logger = get_run_logger() progress_id = create_progress_artifact( progress=0.0, description=f"Download do arquivo {name}, do TSE" ) dest = Path(out_dir) / f"{name}.zip" logger.info(f"Fazendo download do endpoint TSE '{url}' -> {dest}") dest_path = download_stream(url, dest, unzip=True, progress_artifact_id=cast(UUID, progress_id)) print("teste") return dest_path from pathlib import Path from typing import Literal import os enabled_file_extensions = Literal["csv"] def keep_only_files(path: str | Path, file_ext: enabled_file_extensions): dest_path = Path(path) *for item in dest_path.rglob("*"):* if item.is_file() and item.suffix.lower() != f".{file_ext}": print(item.stem) os.remove(item) I want to debug the keep_only_files by printing but it is not showing in the logs debug_mode="False" flush_interval = 5 [results] persist_by_default = "True" # NecessĂĄrio para o cache [logging] level = "INFO" log_prints = "True" [tasks] disable_caching = "False"
m
✅ thought for 229.0 seconds
Thanks for the snippet — a couple of things are going on: 1) Why your prints aren’t showing -
keep_only_files
is a plain function. In Prefect 3.x, print capture only happens when there’s an active run with
log_prints
enabled. If your flow or task doesn’t have
log_prints=True
(or it isn’t enabled globally),
print()
output won’t be redirected to the Prefect logger. - In your config, you have
log_prints = "True"
(a string). TOML booleans must be unquoted:
true
. With the current config, print capture is effectively disabled. Fixes (pick one or combine): - Easiest local fix: enable it on the decorators -
@flow(log_prints=True)
to capture prints from the flow body and any plain functions it calls -
@task(log_prints=True)
on tasks where you want
print()
captured - Or enable globally in your active profile (no quotes): - In profiles.toml:
Copy code
[logging]
    log_prints = true
- Or via env var:
PREFECT_LOGGING_LOG_PRINTS=true
Best practice: - Prefer the Prefect logger over
print()
for anything you need to see in the UI:
Copy code
from prefect import get_run_logger

  def keep_only_files(...):
      logger = get_run_logger()
      <http://logger.info|logger.info>("Removing foo.csv")
Note:
get_run_logger()
only works when called during an active flow/task run. Docs: - Logging and print capture: Add logging - Logger API: prefect.logging.get_run_logger - Settings and profiles: Settings and profiles 2) Ensure
keep_only_files
runs after all downloads finish Right now you kick off many tasks with
.submit(...)
and then immediately call
keep_only_files(...)
. If the downloads are still running, your cleanup might fire too early. Two reliable patterns: - Make
keep_only_files
a task and use `wait_for`:
Copy code
@task(log_prints=True)
  def keep_only_files(path: str | Path, file_ext: enabled_file_extensions):
      logger = get_run_logger()
      dest_path = Path(path)
      for item in dest_path.rglob("*"):
          if item.is_file() and item.suffix.lower() != f".{file_ext}":
              <http://logger.info|logger.info>(f"Removing {item}")
              os.remove(item)

  @flow(log_prints=True)
  def main(refresh_cache=False):
      tse_data_path = "data/tse"
      tse_fs = [
          cast(Any, extract_tse)
          .with_options(refresh_cache=refresh_cache)
          .submit(name, url, tse_data_path)
          for name, url in TSE_ENDPOINTS.items()
      ]
      keep_only_files.submit(tse_data_path, "csv", wait_for=tse_fs)
Docs: Futures, waiting, and dependencies - Or, if you keep it as a plain function, explicitly wait for futures in the flow before calling it:
Copy code
@flow(log_prints=True)
  def main(refresh_cache=False):
      tse_data_path = "data/tse"
      tse_fs = [
          cast(Any, extract_tse)
          .with_options(refresh_cache=refresh_cache)
          .submit(name, url, tse_data_path)
          for name, url in TSE_ENDPOINTS.items()
      ]
      for f in tse_fs:
          f.result()  # wait for completion (raises on failure)
      keep_only_files(tse_data_path, "csv")
3) Small config nits Switch your string booleans to TOML booleans: - Good:
Copy code
[results]
  persist_by_default = true

  [logging]
  level = "INFO"
  log_prints = true

  [tasks]
  disable_caching = false
- Avoid quotes for booleans: `"True"`/`"False"` won’t be interpreted as booleans by Prefect. Relevant docs: - Logging: Add logging - Settings: Settings and profiles
- Concurrency/dependencies: Run work concurrently and Futures API If you share how you’re running the flow (local script vs. via a worker) and your active profile, I can tailor the exact changes.
g
@Marvin from prefect import flow, get_run_logger from prefect.task_runners import ThreadPoolTaskRunner from prefect.futures import resolve_futures_to_results from datetime import date, datetime, timedelta from typing import Any, cast from pathlib import Path from tasks.tse import TSE_ENDPOINTS, extract_tse from tasks import camara from utils.io import merge_ndjson from utils.file import keep_only_files from config.loader import load_config APP_SETTINGS = load_config() # IMPORTAR TASKS TSE, CONGRESSO, SENADO ETC... @flow( task_runner=ThreadPoolTaskRunner(max_workers=APP_SETTINGS.FLOW.MAX_RUNNERS), # type: ignore log_prints=True ) async def pipeline( date: date = datetime.now().date() - timedelta(days=APP_SETTINGS.FLOW.DATE_LOOKBACK), refresh_cache: bool = False ): logger = get_run_logger() logger.info("Iniciando pipeline") # TSE: ~30 endpoints em paralelo tse_data_path = "data/tse" tse_fs = [ cast(Any, extract_tse) .with_options(refresh_cache=refresh_cache) .submit(name, url, tse_data_path) for name, url in TSE_ENDPOINTS.items() ] keep_only_files(path=tse_data_path, file_ext="csv") print("PORRA") from pathlib import Path from typing import cast from uuid import UUID from prefect import task, get_run_logger from prefect.artifacts import create_progress_artifact from datetime import timedelta from utils.io import download_stream from utils.br_data import BR_STATES, ELECTIONS_YEARS from config.loader import load_config, CACHE_POLICY_MAP APP_SETTINGS = load_config() # MONTAR ENDPOINT REDES SOCIAIS POR ESTADO REDES_SOCIAIS_ENDPOINTS = { f"redes_sociais_{year}_{state}": f"{APP_SETTINGS.TSE.BASE_URL}consulta_cand/rede_social_candidato_{year}_{state}.zip" for state in BR_STATES for year in ELECTIONS_YEARS } TSE_ENDPOINTS = { "candidatos_2018": f"{APP_SETTINGS.TSE.BASE_URL}consulta_cand/consulta_cand_2018.zip", "candidatos_2022": f"{APP_SETTINGS.TSE.BASE_URL}consulta_cand/consulta_cand_2022.zip", "prestaca_contas_2018": f"{APP_SETTINGS.TSE.BASE_URL}prestacao_contas/prestacao_de_contas_eleitorais_candidatos_2018.zip", "prestacao_contas_2022": f"{APP_SETTINGS.TSE.BASE_URL}prestacao_contas/prestacao_de_contas_eleitorais_candidatos_2022.zip", "resultado_eleicao_2018": f"{APP_SETTINGS.TSE.BASE_URL}votacao_candidato_munzona/votacao_candidato_munzona_2018.zip", "resultado_eleicao_2022": f"{APP_SETTINGS.TSE.BASE_URL}votacao_candidato_munzona/votacao_candidato_munzona_2022.zip" } TSE_ENDPOINTS = TSE_ENDPOINTS | REDES_SOCIAIS_ENDPOINTS @task( task_run_name="extract_tse_{name}", retries=APP_SETTINGS.TSE.RETRIES, retry_delay_seconds=APP_SETTINGS.TSE.RETRY_DELAY, timeout_seconds=APP_SETTINGS.TSE.TIMEOUT, cache_policy=CACHE_POLICY_MAP[APP_SETTINGS.TSE.CACHE_POLICY], cache_expiration=timedelta(days=APP_SETTINGS.TSE.CACHE_EXPIRATION) ) def extract_tse(name: str, url: str, out_dir: str = "data/tse") -> str: logger = get_run_logger() progress_id = create_progress_artifact( progress=0.0, description=f"Download do arquivo {name}, do TSE" ) dest = Path(out_dir) / f"{name}.zip" logger.info(f"Fazendo download do endpoint TSE '{url}' -> {dest}") dest_path = download_stream(url, dest, unzip=True, progress_artifact_id=cast(UUID, progress_id)) print("teste") return dest_path debug_mode=false flush_interval = 5 [results] persist_by_default = true # NecessĂĄrio para o cache [logging] level = "DEBUG" log_prints = true [tasks] disable_caching = false None of this is working i want only to debug thats why i want to print
m
✅ thought for 109.5 seconds
Got it — thanks for the extra context. You’ve done most of the right things, but there are a few likely culprits. Here’s a tight checklist and fixes to get your prints showing. Quick checklist - Make sure you’re looking at the right place: prints in the flow body (and plain functions it calls) appear in the Flow Run logs; prints inside tasks appear in the Task Run logs. - Your flow is async and uses ThreadPoolTaskRunner — that’s fine; print capture should still work. - You set
log_prints = true
globally and
@flow(log_prints=True)
— good. But your
extract_tse
task doesn’t set
log_prints=True
. It should inherit, but setting it explicitly removes any doubt. -
keep_only_files
is a plain function. To guarantee capture, either use
get_run_logger()
inside it or decorate it as a task with
log_prints=True
. - Confirm the active profile actually has `logging.log_prints = true`:
Copy code
prefect profile inspect
Look for
logging.log_prints = true
in the “Active profile” output. Minimal sanity check (run this exact script) If this doesn’t show the three prints in your logs, there’s an environment/profile issue rather than your code.
Copy code
from prefect import flow, task

@task(log_prints=True)
def t():
    print("task print")

def helper():
    print("helper print")

@flow(log_prints=True)
def f():
    print("flow print")
    helper()
    t.submit()

if __name__ == "__main__":
    f()
Concrete fixes in your code 1) Ensure all debug prints are captured - Make
keep_only_files
a task and use
get_run_logger()
(best for visibility and ordering):
Copy code
import os
from pathlib import Path
from typing import Literal
from prefect import task, get_run_logger

enabled_file_extensions = Literal["csv"]

@task(log_prints=True)
def keep_only_files(path: str | Path, file_ext: enabled_file_extensions):
    logger = get_run_logger()
    dest_path = Path(path)
    for item in dest_path.rglob("*"):
        if item.is_file() and item.suffix.lower() != f".{file_ext}":
            <http://logger.info|logger.info>(f"Removing {item}")
            print(f"DEBUG removing {item.name}")  # will be captured too
            os.remove(item)
- Add
log_prints=True
to `extract_tse`:
Copy code
@task(
    task_run_name="extract_tse_{name}",
    retries=APP_SETTINGS.TSE.RETRIES,
    retry_delay_seconds=APP_SETTINGS.TSE.RETRY_DELAY,
    timeout_seconds=APP_SETTINGS.TSE.TIMEOUT,
    cache_policy=CACHE_POLICY_MAP[APP_SETTINGS.TSE.CACHE_POLICY],
    cache_expiration=timedelta(days=APP_SETTINGS.TSE.CACHE_EXPIRATION),
    log_prints=True,
)
def extract_tse(...):
    ...
    print("teste")  # now captured
    return dest_path
2) Ensure cleanup runs after downloads finish Right now
keep_only_files(...)
runs immediately after you submit the downloads. Use task dependency so it runs after all futures complete:
Copy code
from typing import Any, cast
from prefect import flow, get_run_logger
from prefect.task_runners import ThreadPoolTaskRunner

@flow(
    task_runner=ThreadPoolTaskRunner(max_workers=APP_SETTINGS.FLOW.MAX_RUNNERS),
    log_prints=True,
)
async def pipeline(
    date: date = datetime.now().date() - timedelta(days=APP_SETTINGS.FLOW.DATE_LOOKBACK),
    refresh_cache: bool = False
):
    logger = get_run_logger()
    <http://logger.info|logger.info>("Iniciando pipeline")

    tse_data_path = "data/tse"
    tse_fs = [
        cast(Any, extract_tse)
        .with_options(refresh_cache=refresh_cache)
        .submit(name, url, tse_data_path)
        for name, url in TSE_ENDPOINTS.items()
    ]

    # Ensure cleanup happens after all downloads complete
    keep_only_files.submit(tse_data_path, "csv", wait_for=tse_fs)

    print("PORRA")  # captured in Flow Run logs
3) Verify the active profile is applied Run:
Copy code
prefect profile ls
prefect profile inspect
Confirm you see: -
logging.level = "DEBUG"
-
logging.log_prints = true
-
results.persist_by_default = true
-
tasks.disable_caching = false
If not, switch or set:
Copy code
prefect profile use <your-profile-name>
If it still doesn’t work - Share your Prefect version (
prefect version
). If it’s not current, upgrade — there have been fixes around logging and print capture:
Copy code
pip install -U prefect
- Tell me how you’re running the flow (local script vs. a deployment + worker). If through a worker, confirm the worker logs show flow/task logs. - Run the minimal script above and report whether its prints appear in the UI/console. Docs for reference: - Add logging: Add logging - Logger API: get_run_logger - Futures and waiting: Futures API - Settings and profiles: Settings and profiles If you can paste the output of
prefect version
and
prefect profile inspect
, I’ll pinpoint the next step.
g
@Marvin It works now. But i have a problem. The function is executing before the files exists and it should run only after the process finishes: 145746.957 | INFO | prefect.flow_runs.runner - Runner 'deploy-1' submitting flow run '58979995-de5d-4a15-ab5c-b797e88a36da' 145749.033 | WARNING | prefect.tasks - Both
cache_policy
and
cache_key_fn
are set on task <prefect.tasks.Task object at 0x0000026DA5FF5310>.
cache_key_fn
will be used. 145749.042 | INFO | prefect.flow_runs.runner - Completed submission of flow run '58979995-de5d-4a15-ab5c-b797e88a36da' 145749.763 | INFO | Flow run 'auburn-lemming' - Beginning flow run 'auburn-lemming' for flow 'pipeline' 145749.770 | INFO | Flow run 'auburn-lemming' - View at http://127.0.0.1:4200/runs/flow-run/58979995-de5d-4a15-ab5c-b797e88a36da 145749.771 | INFO | Flow run 'auburn-lemming' - Iniciando pipeline 145750.058 | INFO | Flow run 'auburn-lemming' - executou 145750.071 | INFO | Flow run 'auburn-lemming' - PORRA 145757.019 | INFO | Task run 'extract_tse_candidatos_2022' - Fazendo download do endpoint TSE 'https://cdn.tse.jus.br/estatistica/sead/odsele/consulta_cand/consulta_cand_2022.zip' -> data\tse\candidatos_2022.zip 145757.031 | INFO | Task run 'extract_tse_redes_sociais_2018_AM' - Fazendo download do endpoint TSE 'https://cdn.tse.jus.br/estatistica/sead/odsele/consulta_cand/rede_social_candidato_2018_AM.zip' -> data\tse\redes_sociais_2018_AM.zip 145757.036 | INFO | Task run 'extract_tse_resultado_eleicao_2018' - Fazendo download do endpoint TSE 'https://cdn.tse.jus.br/estatistica/sead/odsele/votacao_candidato_munzona/votacao_candidato_munzona_2018.zip' -> data\tse\resultado_eleicao_2018.zip 145757.042 | INFO | Task run 'extract_tse_redes_sociais_2018_AC' - Fazendo download do endpoint TSE 'https://cdn.tse.jus.br/estatistica/sead/odsele/consulta_cand/rede_social_candidato_2018_AC.zip' -> data\tse\redes_sociais_2018_AC.zip
m
✅ thought for 57.8 seconds
You’re seeing that because the flow continues immediately after submitting the download tasks. Your cleanup runs before the futures complete. Pick one of these patterns: Option A — keep it as a plain function and block until all downloads finish
Copy code
from prefect.futures import resolve_futures_to_results

@flow(log_prints=True, task_runner=ThreadPoolTaskRunner(max_workers=...))
async def pipeline(...):
    ...
    tse_fs = [
        cast(Any, extract_tse)
        .with_options(refresh_cache=refresh_cache)
        .submit(name, url, tse_data_path)
        for name, url in TSE_ENDPOINTS.items()
    ]

    # Block until all futures finish (raise if any failed)
    resolve_futures_to_results(tse_fs)

    # Now it's safe to clean up
    keep_only_files(path=tse_data_path, file_ext="csv")
- Alternatively, you can do:
Copy code
for f in tse_fs:
      f.result()  # surfaces exceptions and blocks until each completes
  keep_only_files(tse_data_path, "csv")
Option B — make cleanup a task and declare the dependency explicitly
Copy code
@task(log_prints=True)
def keep_only_files(...):
    ...

@flow(log_prints=True, task_runner=ThreadPoolTaskRunner(max_workers=...))
async def pipeline(...):
    ...
    tse_fs = [
        cast(Any, extract_tse)
        .with_options(refresh_cache=refresh_cache)
        .submit(name, url, tse_data_path)
        for name, url in TSE_ENDPOINTS.items()
    ]

    # Will not start until all downloads complete
    keep_only_files.submit(tse_data_path, "csv", wait_for=tse_fs)
Notes -
wait_for
only works when the cleanup is a Prefect task; plain functions run immediately. - If any download fails,
f.result()
(or
resolve_futures_to_results
) will raise, so you won’t run cleanup silently on partial data. Refs: - Futures and waiting: Futures API - Concurrency patterns: Run work concurrently
g
@Marvin Will this block all the execution of the flow? Or just the ones that depends on tse?
m
✅ thought for 21.8 seconds
Short answer: - resolve_futures_to_results(tse_fs) or calling f.result() inside the flow blocks only the flow’s execution at that line. It does NOT stop already running tasks. It just prevents the flow from submitting any more work until those futures finish. - Using wait_for on keep_only_files.submit(...) does not block the flow at all; it only delays that specific task until the TSE futures complete. The flow can keep submitting other independent tasks. What to use when: - If you want the flow to continue orchestrating other independent work (e.g., “camara” tasks) while TSE downloads run, prefer:
Copy code
keep_only_files.submit(tse_data_path, "csv", wait_for=tse_fs)
  # flow can continue to submit other tasks here
- If you call:
Copy code
resolve_futures_to_results(tse_fs)
the flow will pause at that line; independent tasks that have already been submitted will continue running, but nothing after that line will be submitted until the TSE futures are done. Patterns - Only block dependent work:
Copy code
tse_fs = [extract_tse.submit(...) for ...]
  keep_only_files.submit(tse_data_path, "csv", wait_for=tse_fs)  # non-blocking for the flow
  # submit other independent tasks after this line; they won’t be blocked
- Intentionally block the flow (no more submissions until done):
Copy code
resolve_futures_to_results(tse_fs)  # blocks here
  keep_only_files(tse_data_path, "csv")  # runs after all TSE downloads finish
Extras - To process results as they complete without blocking, you can use as_completed() or wait() with a timeout: - Docs: Futures API - Concurrency patterns overview: Run work concurrently
g
@Marvin I got this on my main.py file: from flows.pipeline import pipeline if name == "__main__": pipeline.serve(name="deploy-1") And it is giving me the following warning on the IDE: Cannot access attribute "serve" for class "FunctionType" Attribute "serve" is unknown (basedpyright reportFunctionMemberAccess) What is hapenning?
m
✅ thought for 297.7 seconds
Short answer: your IDE’s type checker thinks
pipeline
is just a plain function, so it doesn’t “see” Prefect’s
.serve()
method. At runtime, a Prefect
@flow
-decorated function is a
Flow
object that does have
.serve()
. This warning usually means either: -
pipeline
isn’t actually decorated with
@flow
, or -
@flow
isn’t the outermost decorator, or - it’s a static typing false-positive from basedpyright. What to check/fix: 1) Make sure
pipeline
is decorated with Prefect’s
@flow
Copy code
from prefect import flow

@flow
def pipeline(...):
    ...
2) If you have multiple decorators, put
@flow
at the top so it’s applied last Bad (loses Flow methods):
Copy code
@other_decorator
@flow
def pipeline(...):
    ...
Good:
Copy code
@flow
@other_decorator
def pipeline(...):
    ...
3) If you can’t change the definition (e.g., it’s in another module) but you know it’s a Prefect flow, help the type checker with a cast at the import site
Copy code
from typing import Any, cast
from prefect.flows import Flow
from flows.pipeline import pipeline as _pipeline

pipeline = cast(Flow[..., Any], _pipeline)
pipeline.serve(name="deploy-1")
Or, as a last resort, ignore the warning on that line:
Copy code
pipeline.serve(name="deploy-1")  # pyright: ignore[reportFunctionMemberAccess]
Notes and docs: - In Prefect 3.x, a decorated
@flow
is a
Flow
object that exposes
.serve()
and
.deploy()
. Docs: - Serve a flow: Serve a flow - Deploy with Python (
.deploy
and
from_source(...).deploy
): Deploy via Python If you can, please share the
flows/pipeline.py
definition (especially the decorators) and I can confirm exactly which of the above is causing the warning.
g
@Marvin Ok i did this: from flows.pipeline import pipeline if name == "__main__": # Type ignore Ê utilizado pois o verificado de tipagem do Python encara pipeline como uma função "plana", mas ela possui o mÊtodo serve pois Ê uma função decorada com @flow pipeline.serve( # type: ignore name="deploy-1" ) I was previously developing the software on another environment, on Windows, then i swithed to Linux. I was running uv run prefect server start and on other terminal running uv run src/main.py, where the pipeline function is beign called and it alerady gave me the link to the web GUI where i can start the pipeline but now when i run uv run src/main.py it gives me: 134515.228 | INFO | prefect - Starting temporary server on http://127.0.0.1:8778 See https://docs.prefect.io/v3/concepts/server#how-to-guides for more information on running a dedicated Prefect server. Your flow 'pipeline' is being served and polling for scheduled runs! To trigger a run for this flow, use the following command: $ prefect deployment run 'pipeline/deploy-1' Why
m
✅ thought for 355.2 seconds
Short version: Prefect didn’t find a running API (PREFECT_API_URL not set), so
.serve()
spun up a temporary, headless API just for your process. That mode has no UI, so it prints the CLI command to trigger a run instead of giving you a web link. What changed vs. your old setup - Previously you were running
prefect server start
in another terminal. That starts a dedicated API + UI. You likely used the UI link from that process. - Now you’re just running
uv run src/main.py
. Since there’s no API configured/running, Prefect starts an ephemeral local API (the “Starting temporary server on http://127.0.0.1:8778” message). This ephemeral server does not include the UI, so it tells you to run:
Copy code
prefect deployment run 'pipeline/deploy-1'
How to get the UI back 1) Start a dedicated server in one terminal
Copy code
uv run prefect server start
It will print the UI URL (typically http://127.0.0.1:4200) and the API URL (typically http://127.0.0.1:4200/api). 2) In the other terminal, point Prefect to that API, then run your script - Linux/macOS:
Copy code
export PREFECT_API_URL=<http://127.0.0.1:4200/api>
  uv run src/main.py
- Windows (PowerShell):
Copy code
$env:PREFECT_API_URL="<http://127.0.0.1:4200/api>"
  uv run src\main.py
With the API URL set,
.serve()
will register the deployment to your dedicated server and you’ll be able to trigger runs from the UI. Extra notes - The CLI command it printed is also valid. With
.serve()
running, the local runner will pick up that run:
Copy code
prefect deployment run 'pipeline/deploy-1'
-
webserver=True
on
.serve()
starts a tiny runner monitoring endpoint, not the Prefect UI. Docs - Server concepts and dedicated server how-to: Prefect Server docs - Serving flows (static/local runner): Serve a flow - Deploying with Python (work pools/remote code): Deploy via Python If you’d like, share the exact URL
prefect server start
prints on your machine and I’ll confirm the correct
PREFECT_API_URL
to export.
g
@Marvin I have many tasks in my prefect app. I want to create a parameter to pass to a run to define what tasks should execute or not and to consider the dependencies. How this can be made?
m
✅ thought for 204.2 seconds
Great question. In Prefect 3.x, the flow code defines the task graph, so the most reliable way to “run only a subset of tasks while respecting dependencies” is to: - Add a parameter to your flow (e.g.,
targets
) - Compute the closure of dependencies for those targets - Submit only the required tasks, using
wait_for=[...]
to enforce ordering Notes: - There is no built-in Skip exception/state in Prefect 3.x. Selection is done with normal Python control flow in your flow code. - Task tags are not a runtime filter for execution; they’re for organization and worker routing. Here are two patterns you can use. 1) Simple switches (fastest to implement)
Copy code
from prefect import flow, task

@task
def extract():
    return {"data": "raw"}

@task
def transform(data):
    return {"data": "xformed"}

@task
def load(data):
    return "loaded"

@flow
def etl(run_extract: bool = True, run_transform: bool = True, run_load: bool = True):
    x = extract() if run_extract else None
    t = transform(x) if run_transform and x is not None else None
    _ = load(t) if run_load and t is not None else None
This is fine for a few toggles, but it doesn’t scale well. 2) Select tasks by name and respect dependencies (scalable)
Copy code
from collections import deque
from prefect import flow, task

# Define your tasks
@task
def extract():
    return {"data": "raw"}

@task
def transform(data):
    return {"data": data["data"] + "_xformed"}

@task
def validate(data):
    return True

@task
def load(data):
    return "loaded"

# Register tasks by name and declare dependencies
TASKS = {
    "extract": extract,
    "transform": transform,
    "validate": validate,
    "load": load,
}

# Dependencies by name (a task appears after all items in its list)
DEPS = {
    "extract": [],
    "transform": ["extract"],
    "validate": ["transform"],
    "load": ["transform", "validate"],
}

def dependency_closure(targets, deps):
    required = set()
    stack = list(targets)
    while stack:
        t = stack.pop()
        if t in required:
            continue
        required.add(t)
        stack.extend(deps.get(t, []))
    return required

def topo_order(required, deps):
    # Kahn's algorithm for topological sort on the required subgraph
    indeg = {t: 0 for t in required}
    for t in required:
        for d in deps.get(t, []):
            if d in required:
                indeg[t] += 1
    q = deque([t for t, k in indeg.items() if k == 0])
    order = []
    while q:
        n = q.popleft()
        order.append(n)
        for m in required:
            if n in deps.get(m, []) and m in indeg:
                indeg[m] -= 1
                if indeg[m] == 0:
                    q.append(m)
    return order

@flow
def etl(targets: list[str] | None = None):
    # If no targets provided, run everything
    if not targets:
        targets = list(TASKS.keys())

    required = dependency_closure(targets, DEPS)
    order = topo_order(required, DEPS)

    futures = {}
    results = {}

    for name in order:
        wait_for = [futures[d] for d in DEPS.get(name, []) if d in futures]

        # Wire inputs from upstream results as needed
        kwargs = {}
        if name == "transform":
            kwargs["data"] = results["extract"]
        elif name in ("validate", "load"):
            kwargs["data"] = results["transform"]

        fut = TASKS[name].submit(**kwargs, wait_for=wait_for)
        futures[name] = fut
        results[name] = fut.result()

    # Return only what was run
    return {k: results[k] for k in order}
How you’ll use it: - Run all tasks:
etl()
or
etl(targets=["extract","transform","validate","load"])
- Run only “load”:
etl(targets=["load"])
→ will automatically run “transform” and “extract” and “validate” first - Run a partial:
etl(targets=["transform"])
→ will run “extract” and “transform” This pattern gives you: - A single parameter that picks tasks by name - Automatic inclusion of required dependencies
- Correct execution order via
wait_for
If your tasks pass complex data between each other, adapt the kwargs wiring in the loop. For larger DAGs, you can store the input wiring in a small schema (e.g., map of argument names to upstream task names) instead of
if name == ...
blocks. If this doesn’t match your setup, can you share: - How you want to identify tasks (names, tags, something else)? - Whether tasks depend on upstream results (and how)? - Rough size/shape of the DAG? I can tailor the pattern and provide a ready-to-drop-in helper for your exact graph.
g
@Marvin I got this on my main: if name == "__main__": # Type ignore Ê utilizado pois o verificado de tipagem do Python encara pipeline como uma função "plana", mas ela possui o mÊtodo serve pois Ê uma função decorada com @flow pipeline.serve( # type: ignore name="deploy-1" ) And this is the function that defines my pipeline where the tasks are called: @flow( task_runner=ThreadPoolTaskRunner(max_workers=APP_SETTINGS.FLOW.MAX_RUNNERS), # type: ignore log_prints=True, ) async def pipeline( start_date: date = datetime.now().date() - timedelta(days=APP_SETTINGS.FLOW.DATE_LOOKBACK), end_date: date = datetime.now().date(), refresh_cache: bool = False, ) My question is: I have to run the command prefect deployment run 'pipeline/deploy-1' to execute the run. But how can i pass the start_date and end_date params via the command to execute the run?
m
✅ thought for 75.5 seconds
You can pass flow parameters to a deployment run via
-p/--param
(key=value, JSON-parsed) or
--params
(a JSON object). Since your params are dates, pass them as ISO strings. Examples (bash/macOS/Linux):
Copy code
# Pass each param separately (values are JSON, so strings need double quotes)
prefect deployment run 'pipeline/deploy-1' \
  -p start_date='"2025-01-01"' \
  -p end_date='"2025-01-31"' \
  -p refresh_cache=false \
  --watch
Or with a single JSON payload (often easier, also works well on Windows/PowerShell):
Copy code
prefect deployment run 'pipeline/deploy-1' \
  --params '{"start_date":"2025-01-01","end_date":"2025-01-31","refresh_cache":false}' \
  --watch
Notes: - Values are interpreted as JSON. That’s why string dates must be quoted as JSON strings ("YYYY-MM-DD"). Booleans are lowercase JSON (`true`/`false`). - With your function signature typed as
date
, Prefect will coerce valid ISO date strings to
date
for you. - You can see all options with:
Copy code
prefect deployment run --help
g
@Marvin Do i have any prefect method to get the name of all tasks defined on my app?
m
✅ thought for 183.7 seconds
Short answer: there isn’t a built-in Prefect method that globally lists “all tasks defined in your app.” In Prefect 3.x, tasks aren’t registered globally, and the API stores task runs (runtime), not task definitions. You’ve got two practical options: 1) Static discovery (scan your code for Task objects) - Works if your modules are imported so the
@task
decorators have created Task objects. - Example for a single module:
Copy code
import inspect
from prefect.tasks import Task
import your_app.tasks_module as m  # import your module(s)

task_names = [obj.name for _, obj in inspect.getmembers(m) if isinstance(obj, Task)]
print(task_names)
- Example for an entire package:
Copy code
import pkgutil, importlib, inspect
from prefect.tasks import Task

def all_prefect_tasks_in_package(package_name: str):
    tasks = []
    pkg = importlib.import_module(package_name)
    for _, modname, _ in pkgutil.walk_packages(pkg.__path__, pkg.__name__ + "."):
        try:
            mod = importlib.import_module(modname)
        except Exception:
            continue
        for _, obj in inspect.getmembers(mod):
            if isinstance(obj, Task):
                tasks.append(obj)
    return tasks

tasks = all_prefect_tasks_in_package("your_app")
print([t.name for t in tasks])
Note: this returns the Task objects Prefect created at import time, so make sure your modules are imported. 2) Runtime discovery (query task runs from a flow run) - Useful if you want the tasks that actually executed (and their names/keys).
Copy code
import asyncio
from prefect.client.orchestration import get_client
from prefect.client.schemas.filters import FlowFilter, FlowRunFilter

async def list_task_names_for_latest_run(flow_name: str):
    async with get_client() as client:
        flows = await client.read_flows(flow_filter=FlowFilter(name={"eq_": flow_name}))
        if not flows:
            return []
        flow_id = flows[0].id
        flow_runs = await client.read_flow_runs(flow_filter=FlowRunFilter(flow_id={"any_": [flow_id]}), limit=1)
        if not flow_runs:
            return []
        tr = await client.read_task_runs(flow_run_filter=FlowRunFilter(id={"eq_": flow_runs[0].id}))
        return sorted({t.name for t in tr})

print(asyncio.run(list_task_names_for_latest_run("pipeline")))
If you tell me which you need (a static list of declared tasks in your code vs. tasks that actually ran for a given flow run), I can tailor the snippet.
g
@Marvin from datetime import date, datetime, timedelta from typing import Any, cast from prefect import flow, get_run_logger from prefect.futures import resolve_futures_to_results from prefect.task_runners import ThreadPoolTaskRunner from config.loader import load_config from tasks import camara from tasks.tse import TSE_ENDPOINTS, extract_tse APP_SETTINGS = load_config() @flow( task_runner=ThreadPoolTaskRunner(max_workers=APP_SETTINGS.FLOW.MAX_RUNNERS), # type: ignore log_prints=True, ) async def pipeline( start_date: date = datetime.now().date() - timedelta(days=APP_SETTINGS.FLOW.DATE_LOOKBACK), end_date: date = datetime.now().date(), refresh_cache: bool = False, ignore_tasks: list[str] = [], execute_tasks: list[str] = [], ): logger = get_run_logger() logger.info("Iniciando pipeline") # TSE: ~30 endpoints em paralelo tse_fs = [ cast(Any, extract_tse) .with_options(refresh_cache=refresh_cache) .submit(name, url) for name, url in TSE_ENDPOINTS.items() ] # CÂMARA DOS DEPUTADOS ## LEGISLATURA legislatura = camara.extract_legislatura(start_date, end_date) id_legislatura = legislatura["dados"][0]["id"] ## DEPUTADOS deputados_f = camara.extract_deputados.submit(legislatura) ## ASSIDUIDADE resolve_futures_to_results([deputados_f]) assiduidade_fs = camara.extract_assiduidade_deputados.submit( cast(list[int], deputados_f), start_date, end_date ) ## FRENTES frentes_f = camara.extract_frentes.submit(id_legislatura) ## FRENTES MEMBROS frentes_membros_f = camara.extract_frentes_membros.submit(cast(Any, frentes_f)) ## DETALHES DEPUTADOOS resolve_futures_to_results(frentes_membros_f) detalhes_deputados_fs = camara.extract_detalhes_deputados.submit( cast(list[int], deputados_f) ) ## DISCURSOS DEPUTADOS resolve_futures_to_results(detalhes_deputados_fs) discursos_deputados_fs = camara.extract_discursos_deputados.submit( cast(list[int], deputados_f), start_date, end_date ) ## PROPOSIÇÕES CÂMARA resolve_futures_to_results(discursos_deputados_fs) proposicoes_camara_fs = camara.extract_proposicoes_camara.submit( start_date, end_date ) ## DETALHES PROPOSIÇÕES CÂMARA resolve_futures_to_results([proposicoes_camara_fs]) detalhes_proposicoes_camara_fs = camara.extract_detalhes_proposicoes_camara.submit( cast(list[int], proposicoes_camara_fs) ) ## AUTORES PROPOSIÇÕES CÂMARA resolve_futures_to_results(detalhes_proposicoes_camara_fs) autores_proposicoes_camara_fs = camara.extract_autores_proposicoes_camara.submit( cast(list[int], proposicoes_camara_fs) ) ## DESPESAS DEPUTADOS # # BUGADO ""Parâmetro(s) inválido(s)."" # resolve_futures_to_results([discursos_deputados_fs]) # despesas_deputados_fs = camara.extract_despesas_deputados.submit( # cast(list[int], deputados_f), start_date, legislatura # ) return resolve_futures_to_results( { "tse": tse_fs, "congresso_deputados": deputados_f, "congresso_assiduidade": assiduidade_fs, "congresso_frentes": frentes_f, "congresso_frentes_membros": frentes_membros_f, "congresso_detalhes_deputados": detalhes_deputados_fs, "congresso_discurso_deputados": discursos_deputados_fs, "congresso_proposicoes": proposicoes_camara_fs, "congresso_detalhes_proposicoes": detalhes_proposicoes_camara_fs, "autores_proposicoes_camara_fs": autores_proposicoes_camara_fs, # "congresso_despesas_deputados": despesas_deputados_fs, } ) if name == "__main__": pipeline.serve( # type: ignore name="deploy-1" ) This is my pipeline.py file. At the end i got return resolve_futures_to_results... Do i need this return?
m
✅ thought for 132.3 seconds
Short answer: you don’t need
resolve_futures_to_results(...)
at the end of the flow unless you specifically want to return plain Python values instead of Prefect State objects. Details: - In Prefect 3.x, when a flow finishes, the engine automatically resolves any returned PrefectFutures to terminal States. If you return a dict/list of futures, the caller receives the same structure but with State objects instead of futures. - If you prefer to return the raw results (not State objects), then keeping
resolve_futures_to_results(...)
on the final return is fine. Otherwise, you can just return the futures structure: Example:
Copy code
# Option A: return futures (recommended default)
return {
    "tse": tse_fs,
    "congresso_deputados": deputados_f,
    "congresso_assiduidade": assiduidade_fs,
    ...
}

# The caller gets States; call .result() on them if needed.
Copy code
# Option B: return raw results
return resolve_futures_to_results({
    "tse": tse_fs,
    "congresso_deputados": deputados_f,
    ...
})
A few additional notes on your current flow: - The intermediate
resolve_futures_to_results(...)
calls you use (e.g.,
resolve_futures_to_results([deputados_f])
) are only acting as barriers and their return values aren’t used. If a downstream task takes an upstream future as an argument (which yours do), Prefect will handle dependency ordering and resolve inputs automatically. You can remove those “wait” calls unless you explicitly need to block before starting the next stage. - One call looks unnecessary and adds unintended serialization:
resolve_futures_to_results(frentes_membros_f)
happens right before scheduling
detalhes_deputados_fs
, which doesn’t depend on
frentes_membros_f
. You can drop that unless you truly want to wait there. - Because your flow is async,
resolve_futures_to_results
is synchronous; using it more than necessary can reduce parallelism. If you tell me whether you want the flow’s return to be raw data or State objects, I can suggest the minimal changes to your file.
g
async def pipeline( start_date: date = datetime.now().date() - timedelta(days=APP_SETTINGS.FLOW.DATE_LOOKBACK), end_date: date = datetime.now().date(), refresh_cache: bool = False, ignore_tasks: list[str] = [], How do i run this deploy: uv run prefect deployment run 'pipeline/deploy-1' -p ignore_tasks=["extract_camara_despesas_deputados"] Im trying this but it is not working
@Marvin async def pipeline( start_date: date = datetime.now().date() - timedelta(days=APP_SETTINGS.FLOW.DATE_LOOKBACK), end_date: date = datetime.now().date(), refresh_cache: bool = False, ignore_tasks: list[str] = [], How do i run this deploy: uv run prefect deployment run 'pipeline/deploy-1' -p ignore_tasks=["extract_camara_despesas_deputados"] Im trying this but it is not working
m
✅ thought for 89.3 seconds
You’re close—the issue is quoting.
-p/--param
values are parsed as JSON, so lists must be passed as a JSON array and the whole value must be shell-quoted. Try one of these: - Bash/zsh:
Copy code
uv run prefect deployment run 'pipeline/deploy-1' \
  -p ignore_tasks='["extract_camara_despesas_deputados"]' \
  --watch
- Cross-shell (often simplest):
Copy code
uv run prefect deployment run 'pipeline/deploy-1' \
  --params '{"ignore_tasks":["extract_camara_despesas_deputados"]}' \
  --watch
- PowerShell:
Copy code
uv run prefect deployment run 'pipeline/deploy-1' `
  --params '{\"ignore_tasks\":[\"extract_camara_despesas_deputados\"]}' `
  --watch
(or escape the inner quotes if using `-p`:
-p ignore_tasks="[\"extract_camara_despesas_deputados\"]"
) Tips: -
-p
values are JSON, so strings need quotes and booleans use `true`/`false`. - Param names must match your flow signature exactly (
ignore_tasks
). - Optional: avoid mutable defaults in your flow definition; use
None
and set a default in the body:
Copy code
async def pipeline(..., ignore_tasks: list[str] | None = None, execute_tasks: list[str] | None = None):
    ignore_tasks = ignore_tasks or []
    execute_tasks = execute_tasks or []
If it still fails, can you share the exact error message you get from the CLI?
g
@Marvin I want to see which flows/tasks failed: Above i have the flow being called by other flow: if FlowsNames.SENADO not in ignore_flows: futures.append(run_senado_flow.submit(start_date, end_date, ignore_tasks)) # Bloquea a execução do código atÊ que todos os flows sejam finalizados done, not_done = wait(futures) flow_states = resolve_futures_to_states(done) the main flow is calling a task that calls the senado flow: @task( name="Run Senado Flow", task_run_name="run_senado_flow", description="Task que permite executar o Flow do Senado de forma concorrente em relação às outras flows.", ) def run_senado_flow(start_date: date, end_date: date, ignore_tasks: list[str]): senado_flow(start_date, end_date, ignore_tasks) but when i log done or not done to see what failed i get: 165232.871 | INFO | Flow run 'pipeline_flow' - {<prefect.futures.PrefectConcurrentFuture object at 0x7f747847f620>}
m
✅ thought for 181.9 seconds
You’re just printing the Python objects for the futures; to see what actually failed you need to inspect each future’s state (or resolve them to states/results). In Prefect 3.x: - wait(futures) returns two sets: done and not_done of PrefectFuture objects - Each future has a .state you can inspect - state.is_failed() tells you if it failed - state.result(raise_on_failure=False) returns the exception object for failed states (without raising) A simple pattern that also preserves names is to keep a dict of futures keyed by a logical name, then resolve to states:
Copy code
from prefect.futures import wait, resolve_futures_to_states

futures = {}

if FlowsNames.SENADO not in ignore_flows:
    futures["senado"] = run_senado_flow.submit(start_date, end_date, ignore_tasks)

# Wait for completion (optional timeout=...)
done, not_done = wait(futures.values())

# Turn futures into State objects while keeping your keys (names)
states = resolve_futures_to_states(futures)

# Report statuses
for name, state in states.items():
    if state.is_failed():
        exc = state.result(raise_on_failure=False)
        print(f"[FAILED] {name}: {type(exc).__name__}: {exc}")
    elif state.is_completed():
        print(f"[OK] {name}")
    else:
        print(f"[{state.type.value}] {name}")
If you don’t keep a dict and only have the set from done, you can still inspect each future:
Copy code
from prefect.futures import wait

done, not_done = wait(futures)

for fut in done:
    st = fut.state
    if st.is_failed():
        exc = st.result(raise_on_failure=False)
        print(f"Task failed: {type(exc).__name__}: {exc}")
    else:
        print(f"Task state: {st.type.value}")
Notes on your current setup: - Your
run_senado_flow
task calls
senado_flow(...)
directly. If the subflow raises, the task will fail and you’ll see that failure on the task’s future. - If you want to inspect the subflow’s state without immediately raising, call it with
return_state=True
inside the task:
Copy code
@task
  def run_senado_flow(start_date: date, end_date: date, ignore_tasks: list[str]):
      state = senado_flow(return_state=True, start_date=start_date, end_date=end_date, ignore_tasks=ignore_tasks)
      # Optionally bubble up failure so the task fails:
      state.result()  # raises if the subflow failed
      return "done"
- When you call
resolve_futures_to_states(done)
on a set, you lose the names. Prefer passing a dict keyed by a descriptive name as shown above so your report can say “senado failed”. If you want the Prefect-assigned task run name in logs (e.g., your
task_run_name="run_senado_flow"
), the quickest way is to key your futures dict with that same label. If you really need the orchestration-assigned name, you can read it from the API using the client, but the dict-key approach is simpler and faster within the flow.
g
@Marvin done, not_done = wait(futures) flow_states = resolve_futures_to_states(futures) I got this. But i want the name of the task that failed (the task is calling a flow, the error is raised in the lower flow)
m
✅ thought for 256.5 seconds
Got it — you want the actual task run name for the one that failed (even if the failure originated in the subflow it called). Two solid options: 1) Keep a dict of futures keyed by your own names (simple) 2) Look up the Prefect task run name from the API using the task run ID on the state Since you already have a list, here’s a drop‑in that uses the API to fetch the task run name and (optionally) the subflow run name:
Copy code
from prefect.futures import wait, resolve_futures_to_states
from prefect.client.orchestration import get_client

# futures is your existing list of futures
done, not_done = wait(futures)
states = resolve_futures_to_states(futures)

client = get_client(sync_client=True)

for fut, st in zip(futures, states):
    if st.is_failed():
        # Task run name
        task_run_id = st.state_details.task_run_id
        if task_run_id:
            tr = client.read_task_run(task_run_id=task_run_id)
            print(f"[FAILED TASK] {tr.name} ({tr.id})")
        else:
            print("[FAILED TASK] <unknown task run>")

        # If failure came from a subflow, you can also report the subflow run
        child_flow_run_id = st.state_details.child_flow_run_id
        if child_flow_run_id:
            fr = client.read_flow_run(flow_run_id=child_flow_run_id)
            print(f"  Subflow: {fr.name} ({fr.id})")

        # Exception (without raising)
        exc = st.result(raise_on_failure=False)
        print(f"  Exception: {type(exc).__name__}: {exc}")
If you’d prefer not to call the API, keep a dict keyed by a descriptive name so you already “have” the name:
Copy code
futures = {}
futures["Run Senado Flow"] = run_senado_flow.submit(start_date, end_date, ignore_tasks)

states = resolve_futures_to_states(futures)
for name, st in states.items():
    if st.is_failed():
        exc = st.result(raise_on_failure=False)
        print(f"[FAILED] {name}: {type(exc).__name__}: {exc}")
Notes: - The failure in the subflow will mark the calling task as FAILED. The task’s state contains: -
state.state_details.task_run_id
→ fetch task run name -
state.state_details.child_flow_run_id
→ the subflow’s flow run (useful to log which subflow failed) - You can also change your task to call the subflow with
return_state=True
if you want to explicitly inspect/log the subflow’s state inside the task before bubbling the error:
Copy code
@task(name="Run Senado Flow", task_run_name="run_senado_flow")
  def run_senado_flow(start_date: date, end_date: date, ignore_tasks: list[str]):
      st = senado_flow(return_state=True, start_date=start_date, end_date=end_date, ignore_tasks=ignore_tasks)
      # log child flow run details here if desired
      st.result()  # raises if the subflow failed, causing this task to fail
g
@Marvin So, on my pipeline, i got a main flow that calls tasks (run_senado_flow). Each one of these tasks call one flow (senado_flow) and this flow call other tasks(download_deput). What i noticed is that if a task (download_deput) raises an error, it waits until the end of the flow (senado_flow) to show the error, i think on resolve_futures_to_results: extract_senadores_f = extract_senadores.submit() resolve_futures_to_results( [extract_senado_colegiados_f, extract_despesas_senadores_f, extract_votacoes_f] )
m
✅ thought for 393.7 seconds
You're right about the behavior you’re seeing. In Prefect 3.x, exceptions from tasks are only raised when their futures are resolved (e.g., when you call
.result()
or
resolve_futures_to_results
). If a future is never resolved until the end of the flow, you won’t see the exception until then. In your snippet, you create
extract_senadores_f
but only resolve other futures:
Copy code
extract_senadores_f = extract_senadores.submit()

resolve_futures_to_results(
    [extract_senado_colegiados_f, extract_despesas_senadores_f, extract_votacoes_f]
)
If
download_deput
fails under
extract_senadores_f
, that explains why you only see the error at the end: you’re not resolving
extract_senadores_f
earlier. Ways to surface failures earlier (pick what matches your intent): 1) Fail fast as tasks finish with as_completed - Raises as soon as a failing future completes (best for early visibility)
Copy code
from prefect.futures import as_completed

futs = [
    extract_senado_colegiados.submit(),
    extract_despesas_senadores.submit(),
    extract_votacoes.submit(),
    extract_senadores.submit(),  # include this one too
]

for f in as_completed(futs):
    # raises immediately for failed tasks
    f.result()  # or f.result(raise_on_failure=True)
2) Inspect all outcomes with wait (no immediate raise) - Lets you log/handle failures without throwing right away
Copy code
from prefect.futures import wait

futs = [
    extract_senado_colegiados.submit(),
    extract_despesas_senadores.submit(),
    extract_votacoes.submit(),
    extract_senadores.submit(),
]

done, not_done = wait(futs)
for f in done:
    if f.state.is_completed():
        _ = f.result()
    else:
        # inspect without raising
        _ = f.result(raise_on_failure=False)
        # log / collect failure here
3) Keep resolve_futures_to_results but include all futures you care about - Simple change if you like your current pattern
Copy code
resolve_futures_to_results(
    [extract_senado_colegiados_f, extract_despesas_senadores_f, extract_votacoes_f, extract_senadores_f]
)
4) Raise immediately for a specific future - If you want to stop right after a particular step
Copy code
extract_senadores_f = extract_senadores.submit()
extract_senadores_f.result()  # raises now if it failed
# then run the rest...
5) Emit a signal on failure regardless of when you resolve - Add an on_failure hook to your task so you see a log/notification right when it fails
Copy code
from prefect import task

def notify_on_failure(state, obj, **kwargs):
    print(f"Task {obj.name} failed: {state.message}")

@task(on_failure=[notify_on_failure])
def download_deput(...):
    ...
Notes and best practices - If you’re calling a subflow from a task, make sure you await/resolve the subflow’s future if you want its failures to propagate to the parent at the desired time. Prefer calling subflows from flows (not tasks) when possible for clearer orchestration. - Use
as_completed
if your goal is fail-fast visibility. Use
wait
when you want to collect and handle all outcomes without immediately raising. -
resolve_futures_to_states
won’t raise; it just returns final states. Use it if you want to inspect status first, then decide what to raise. If you can share which future actually contains
download_deput
and where you intend to stop the flow on failure (fail-fast vs. “gather errors and continue”), I can suggest the cleanest refactor for your exact structure.
g
@Marvin Im going to pass to you one of my flows. I want you to revise the code to see if im commiting something wrong. It has been working very good but i dont know if it is the pattern of Prefect: @flow( name="Senado Flow", flow_run_name="senado_flow", description="Orquestramento de tasks do endpoint Senado.", log_prints=True, ) def senado_flow(start_date: date, end_date: date, ignore_tasks: list[str]): logger = get_run_logger() logger.info("Iniciando execução da Flow do Senado") ## COLEGIADOS extract_senado_colegiados_f = None if TasksNames.EXTRACT_SENADO_COLEGIADOS not in ignore_tasks: extract_senado_colegiados_f = extract_colegiados.submit() ## SENADORES extract_senadores_f = None if TasksNames.EXTRACT_SENADO_SENADORES not in ignore_tasks: extract_senadores_f = extract_senadores.submit() ## DETALHES SENADORES extract_detalhes_senadores_f = None if ( extract_senadores_f is not None and TasksNames.EXTRACT_SENADO_DETALHES_SENADORES not in ignore_tasks ): extract_detalhes_senadores_f = extract_detalhes_senadores.submit( extract_senadores_f # type: ignore ) resolve_futures_to_results(extract_detalhes_senadores_f) ## DISCURSOS SENADORES extract_discursos_senadores_f = None if ( extract_senadores_f is not None and TasksNames.EXTRACT_SENADO_DISCURSOS_SENADORES not in ignore_tasks ): extract_discursos_senadores_f = extract_discursos_senadores.submit( extract_senadores_f, # type: ignore start_date, end_date, ) resolve_futures_to_results(extract_discursos_senadores_f) ## DESPESAS SENADORES extract_despesas_senadores_f = None if TasksNames.EXTRACT_SENADO_DESPESAS_SENADORES not in ignore_tasks: extract_despesas_senadores_f = extract_despesas_senadores.submit( start_date, end_date ) ## PROCESSOS SENADO extract_processos_f = None if TasksNames.EXTRACT_SENADO_PROCESSOS not in ignore_tasks: extract_processos_f = extract_processos.submit(start_date, end_date) ## DETALHES PROCESSOS extract_detalhes_processos_f = None if ( extract_processos_f is not None and TasksNames.EXTRACT_SENADO_DETALHES_PROCESSOS not in ignore_tasks ): extract_detalhes_processos_f = extract_detalhes_processos.submit( extract_processos_f # type: ignore ) resolve_futures_to_results(extract_detalhes_processos_f) ## VOTACOES extract_votacoes_f = None if TasksNames.EXTRACT_SENADO_VOTACOES not in ignore_tasks: extract_votacoes_f = extract_votacoes.submit(start_date, end_date) # Para finalizar o Flow corretamente na GUI do servidor, Ê preciso resolver os futures dos endpoints que não foram passados para outras tasks. resolve_futures_to_results( [extract_senado_colegiados_f, extract_despesas_senadores_f, extract_votacoes_f] )
m
✅ thought for 116.2 seconds
Thanks for sharing the flow — the overall structure is solid for Prefect 3.x: you’re imperatively submitting tasks, wiring dependencies by passing futures as arguments, and using an ignore list to gate work. The main thing I’d tweak is how/when you resolve futures to ensure: - All scheduled work is accounted for (so hidden failures don’t get ignored) - You can surface failures earlier if desired - You keep maximal concurrency (avoid serializing work by resolving too early) Notes on your current pattern - Passing a future (e.g.,
extract_senadores_f
) into downstream tasks is the right way to declare dependencies. You don’t need to resolve the upstream future before submitting dependents — Prefect will handle ordering. - Calling
resolve_futures_to_results
immediately after some submits (e.g.,
extract_detalhes_*
,
extract_discursos_*
) forces those steps to complete right there. That’s fine if you want to fail-fast at that point, but it does serialize those branches and can reduce concurrency. - At the end, you’re only resolving three futures:
[extract_senado_colegiados_f, extract_despesas_senadores_f, extract_votacoes_f]
. If
extract_senadores_f
or
extract_processos_f
are scheduled (and their dependent details tasks are skipped by
ignore_tasks
), failures in those could be missed because they’re never resolved. In Prefect 3.x, a flow does not automatically fail because a task failed unless you resolve its future (or otherwise surface the failure). A cleaned-up version with two options (fail-fast as tasks complete, or resolve at the end) ``` from datetime import date from typing import Sequence, Optional from prefect import flow, get_run_logger from prefect.futures import as_completed, resolve_futures_to_results @flow( name="Senado Flow", flow_run_name="senado_flow-{start_date:%Y%m%d}-{end_date:%Y%m%d}", description="Orquestramento de tasks do endpoint Senado.", log_prints=True, ) def senado_flow(start_date: date, end_date: date, ignore_tasks: Sequence[str], fail_fast: bool = True): logger = get_run_logger() logger.info("Iniciando execução da Flow do Senado") futures = [] # COLEGIADOS if TasksNames.EXTRACT_SENADO_COLEGIADOS not in ignore_tasks: fut = extract_colegiados.submit() futures.append(fut) # SENADORES senadores_f: Optional[PrefectFuture] = None if TasksNames.EXTRACT_SENADO_SENADORES not in ignore_tasks: senadores_f = extract_senadores.submit() futures.append(senadores_f) # DETALHES SENADORES (depends on senadores) if ( senadores_f is not None and TasksNames.EXTRACT_SENADO_DETALHES_SENADORES not in ignore_tasks ): fut = extract_detalhes_senadores.submit(senadores_f) futures.append(fut) # DISCURSOS SENADORES (depends on senadores) if ( senadores_f is not None and TasksNames.EXTRACT_SENADO_DISCURSOS_SENADORES not in ignore_tasks ): fut = extract_discursos_senadores.submit(senadores_f, start_date, end_date) futures.append(fut) # DESPESAS SENADORES if TasksNames.EXTRACT_SENADO_DESPESAS_SENADORES not in ignore_tasks: fut = extract_despesas_senadores.submit(start_date, end_date) futures.append(fut) # PROCESSOS SENADO processos_f: Optional[PrefectFuture] = None if TasksNames.EXTRACT_SENADO_PROCESSOS not in ignore_tasks: processos_f = extract_processos.submit(start_date, end_date) futures.append(processos_f) # DETALHES PROCESSOS (depends on processos) if ( processos_f is not None and TasksNames.EXTRACT_SENADO_DETALHES_PROCESSOS not in ignore_tasks ): fut = extract_detalhes_processos.submit(processos_f) futures.append(fut) # OPTION A: Fail fast as each task completes (early visibility of errors) if fail_fast: for f in as_completed([f for f in futures if f is not None]): # raises immediately on failure/crash/cancel
f.result() return # OPTION B: Resolve all at the end (keeps concurrency, surfaces all failures together) resolve_futures_to_results([f for f in futures if f is not None])
Copy code
Why this pattern helps
- Every submitted future is tracked in a single list. Nothing gets “lost,” so failures in any branch will be surfaced either as they complete (Option A) or at the end (Option B).
- You avoid immediate `resolve_futures_to_results` inside each block, which can unnecessarily serialize work. If you do want to stop the flow right after specific steps, you can still call `.result()` for just those futures right after submit.
- Using `as_completed` is the simplest way to see failures as soon as they finish, without waiting for the rest.

Optional refinements
- If some tasks are truly “best-effort,” you can safely inspect their `state` after `wait` or call `f.result(raise_on_failure=False)` and decide whether to continue.
- Add `on_failure` hooks to tasks you want to notify/log immediately upon failure (independent of future resolution).
- Your flow_run_name is currently static; I made it dynamic so runs are easy to identify by dates.

If you prefer to keep your current structure, the minimal fix is to ensure all possibly-submitted roots are included in the final resolution list, e.g. also include `extract_senadores_f` and `extract_processos_f` when their downstreams are ignored:
resolve_futures_to_results( [extract_senado_colegiados_f, extract_despesas_senadores_f, extract_votacoes_f, extract_senadores_f, extract_processos_f] ) ``` If you share which branches are “critical” (should stop the flow immediately) vs “best effort,” I can tune the pattern accordingly (mix of early
.result()
on critical futures and end-of-flow resolution for the rest).
g
@Marvin I got a task that executes a function that is not decorated as a taks (is a function that handles data fetch). I am not raising errors from the data fetch function to the task because i want this part to not mark the task as error if occurs, i want to handle it in a personalized way. Inside the data fetch function i got a call to a database, but if the call to the db has an error i want the task status to be error. How can i raise the error from the db call to the task without raising the error from the data fetch function?
m
✅ thought for 158.7 seconds
Great question — you want selective failure propagation: - Your task calls a plain Python helper (data fetch). Most errors in that helper should be handled without failing the task. - But if the DB call inside that helper fails, you want the task to fail (so the run is marked as Failed and retries/alerts apply). Two solid patterns for Prefect 3.x 1) Preferred: Return a structured result from the helper, and raise in the task only for “fatal” (DB) errors - The helper does not raise; it classifies errors as fatal vs non-fatal and returns that info. - The task inspects the result and raises for fatal DB errors.
Copy code
from dataclasses import dataclass
from typing import Any, Optional
from prefect import task

# Replace this with your DB library exception(s)
class DatabaseError(Exception):
    pass

@dataclass
class FetchResult:
    ok: bool
    data: Optional[Any] = None
    error: Optional[Exception] = None
    fatal: bool = False  # True for DB errors

def fetch_data(... ) -> FetchResult:
    try:
        # ... other fetch logic that might have non-fatal issues
        # db call
        data = call_db(...)  # may raise DatabaseError
        return FetchResult(ok=True, data=data)
    except DatabaseError as e:
        # mark as fatal but do not raise here
        return FetchResult(ok=False, error=e, fatal=True)
    except Exception as e:
        # non-fatal, handled case
        return FetchResult(ok=False, error=e, fatal=False)

@task(
    retries=3,
    # only retry on DB errors
    retry_condition_fn=lambda exc: isinstance(exc, DatabaseError),
)
def my_task(...):
    result = fetch_data(...)
    if not result.ok and result.fatal:
        # Fail the task for DB errors
        # Option A: raise original to preserve traceback
        raise result.error
        # Option B: wrap it
        # raise RuntimeError("DB fetch failed") from result.error

    if not result.ok:
        # handled, non-fatal path (do custom logging/metrics)
        # continue or return partial result
        return None

    return result.data
Why this is good: - The helper never raises (as you wanted). - The task controls failure semantics and keeps Prefect’s retry/failure behavior. -
retry_condition_fn
lets you retry only for DB exceptions. 2) Alternative: Programmatically mark the task as failed (less common) - If you truly must not raise but still want the run marked Failed, you can set the state. - Note: raising is still recommended to stop execution and trigger retries.
Copy code
from prefect import task
from prefect.states import Failed
from prefect.context import TaskRunContext

@task
def my_task(...):
    result = fetch_data(...)
    if not result.ok and result.fatal:
        ctx = TaskRunContext.get()
        ctx.set_state(Failed(message=f"DB failure: {result.error}"))
        # Strongly recommended to raise as well so execution stops and retries apply
        raise result.error
Extra tips - Narrow your exception handling in the helper so only non-fatal cases are swallowed; let unexpected exceptions surface as fatal via the FetchResult or by re-raising them if you change your mind later. - If you want to treat certain DB errors as non-fatal, you can encode that in the helper’s classification logic. - You can also add an on_failure hook to the task for immediate logging/alerts even before futures are resolved. If you share the DB client you’re using (psycopg, SQLAlchemy, etc.), I can tailor the
DatabaseError
handling and the retry condition precisely.
g
@Marvin 124326.365 | ERROR | prefect - Erro ao inserir dados na tabela erros_extract: Unconsumed column names: lote_i Traceback (most recent call last): File "/home/gustavo/Code/prisma-parlamentar/pipeline/src/utils/fetch_many_jsons.py", line 93, in worker response.raise_for_status() ~~~~~~~~~~~~~~~~~~~~~~~~~^^ File "/home/gustavo/Code/prisma-parlamentar/pipeline/.venv/lib/python3.13/site-packages/httpx/_models.py", line 829, in raise_for_status raise HTTPStatusError(message, request=request, response=self) httpx.HTTPStatusError: Client error '400 Bad Request' for url 'https://legis.senado.leg.br/dadosabertos/processo/2845253?v=1ERRO' For more information check: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/400 During handling of the above exception, another exception occurred: ... I can propagate the error on the db (by other metohd that you suggested), but the deployment does not finish, prefect logs the error but the execution freezes after some errors logged
m
✅ thought for 384.6 seconds
Thanks for the details — sorry you’re hitting a “freeze”. In Prefect 3.x that almost always comes from one of these patterns: - Unresolved Prefect futures (submitted tasks that you never
wait()
on or call
.result()
for) - Background threads/processes started inside a task that are not shut down - Blocking sync code running in worker threads where timeouts can’t interrupt - Nested executors (your own ThreadPool/ProcessPool inside the default ThreadPoolTaskRunner) that don’t get joined/shut down Given your traceback and note about “worker” in
fetch_many_jsons.py
, I suspect you’re starting your own threads in a task and not joining them, or you’re submitting tasks and not resolving their futures. That would explain “Prefect logs the error” but the run never exits. What to change/check right away 1) Always resolve submitted futures - If you use
.submit()
, make sure you
wait()
or
.result()
on all terminal futures before the flow ends.
Copy code
from prefect import flow, task
from prefect.futures import wait
import httpx

@task
def fetch_one(url):
    try:
        r = httpx.get(url, timeout=10)
        r.raise_for_status()
        return r.json()
    except Exception as exc:
        # Log and/or persist the error, but return from the task
        from prefect import get_run_logger
        get_run_logger().error(f"Failed {url}: {exc}")
        return {"url": url, "error": str(exc)}

@flow
def fetch_many(urls):
    futs = [fetch_one.submit(u) for u in urls]
    wait(futs)  # ensure all tasks finish (success or fail)
    results = [f.result() for f in futs]
    return results
2) If you spawn your own threads, always join or use an executor context manager - Un-joined, non-daemon threads will keep the process alive after Prefect finishes orchestration.
Copy code
from concurrent.futures import ThreadPoolExecutor, as_completed

def run_workers(items):
    # DO use a context manager so threads are joined on exit
    with ThreadPoolExecutor(max_workers=10) as ex:
        futs = [ex.submit(worker, it) for it in items]
        for f in as_completed(futs):
            f.result()  # re-raise exceptions, ensures nothing is left pending
3) Prefer async tasks or ProcessPool for reliable timeouts - In worker threads (default ThreadPoolTaskRunner), timeouts cannot interrupt blocking sync calls. - If you need reliable interruption: make the task async or switch to ProcessPoolTaskRunner.
Copy code
from prefect import flow, task
from prefect.task_runners import ProcessPoolTaskRunner
import time

@task(timeout_seconds=2)
def sync_blocking():
    time.sleep(10)  # Will be interrupted when run in a process

@flow(task_runner=ProcessPoolTaskRunner())
def my_flow():
    f = sync_blocking.submit()
    return f.result()
Docs: Task timeout behavior and Task runners concept 4) Don’t swallow exceptions in background workers - If you catch exceptions inside a background thread and only log them (never letting Prefect see them or return something terminal), the flow can’t make progress. Either: - Return a value (success/error payload) from the task so Prefect can finish, or - Let the exception propagate at the task boundary so Prefect marks the task failed. 5) Ensure DB work is thread-safe and connections are closed - Use context-managed sessions per task; avoid sharing DB connections across threads. - The error “Unconsumed column names: lote_i” suggests a mismatched insert mapping. Fix the column name or mapping so your exception path isn’t repeatedly failing/looping. 6) Add a flow-level timeout as a safety net
Copy code
from prefect import flow

@flow(timeout_seconds=1800)  # 30 minutes
def main_flow(...):
    ...
Good references - Timeouts and behavior by execution context: Write and run workflows: Task timeout behavior
- Concurrency and runners: Task runners and Run work concurrently - Timeout utilities: Timeout utilities A couple of clarifying questions to pinpoint the freeze: - Which task runner are you using in the flow (default ThreadPoolTaskRunner, ProcessPoolTaskRunner, etc.)? - In
fetch_many_jsons.py
, are you creating your own ThreadPool/threads inside a Prefect task? If so, can you confirm you’re using a context manager and waiting/joining all workers? - Are you using
.submit()
for tasks, and if yes, do you call
wait()
or
.result()
on all of them before the flow returns? - In the Prefect UI, does the flow run remain “Running” indefinitely, or does it complete while the process stays alive? If you can share a small snippet of the flow function (how you submit/await tasks) and the worker code in
fetch_many_jsons.py
, I can suggest a precise fix.
g
@Marvin The log that keeps running after the freeze: _queue.Empty 125604.120 | DEBUG | APILogWorkerThread | prefect._internal.concurrency - Running call get(timeout=1.9999922670003798) in thread 'APILogWorkerThread' 125604.120 | DEBUG | APILogWorkerThread | prefect._internal.concurrency - <WatcherThreadCancelScope, name='get' RUNNING, runtime=0.00> entered 125605.261 | DEBUG | APILogWorkerThread | prefect._internal.concurrency - <WatcherThreadCancelScope, name='get' COMPLETED, runtime=2.00> exited 125605.261 | DEBUG | APILogWorkerThread | prefect._internal.concurrency - Encountered exception in call get(<dropped>) Traceback (most recent call last): File "/home/gustavo/Code/prisma-parlamentar/pipeline/.venv/lib/python3.13/site-packages/prefect/_internal/concurrency/calls.py", line 399, in _run_sync result = self.fn(*self.args, **self.kwargs) File "/home/gustavo/.local/share/uv/python/cpython-3.13.11-linux-x86_64-gnu/lib/python3.13/queue.py", line 209, in get raise Empty
m
✅ thought for 494.5 seconds
Thanks for sharing the snippet — that helps. What you’re seeing is expected behavior from Prefect’s background API log worker: - The APILogWorker runs in a background thread (named “APILogWorkerThread”) that batches logs and sends them to the API. - It uses `queue.get(timeout=...)`; when the timeout expires, Python raises
queue.Empty
. Prefect catches that to flush the batch based on the interval. At DEBUG level, you’ll see that exception repeatedly about every 2 seconds (the default batch interval). - The WatcherThreadCancelScope messages are Prefect’s internal concurrency traces around that
get()
call. In short: those
queue.Empty
traces are normal at DEBUG and not the cause of a freeze. If your process appears frozen, this worker isn’t the culprit — it’s just idling and logging. We should look elsewhere for the hang. A few quick steps: - Turn down the internal log noise so it’s easier to spot real issues: - CLI:
Copy code
prefect config set PREFECT_LOGGING_INTERNAL_LEVEL=INFO
- Or env var:
PREFECT_LOGGING_INTERNAL_LEVEL=INFO
- As an isolation test, you can disable API logging and see if behavior changes: - CLI:
Copy code
prefect config set PREFECT_LOGGING_TO_API_ENABLED=false
- Or env var:
PREFECT_LOGGING_TO_API_ENABLED=false
- Please share: - Output of
Copy code
prefect version
- Whether you’re running locally or via a worker/work pool - Where the “freeze” occurs in your run (e.g., a specific task, after the flow ends, only when sending logs, etc.) Notes on Python 3.13: - Prefect 3.x supports Python 3.13 (make sure you’re on a recent 3.x release). If you’re on an older 3.x, upgrade and retry. - There’s no known 3.13-specific issue with APILogWorker,
queue.Empty
, or
WatcherThreadCancelScope
causing hangs. - Related but separate: there is an open issue about missing Prefect Docker images for certain Prefect/Python combos when auto-generating Docker configs from
flow.deploy
on 3.13 — this doesn’t affect local execution but can affect deployment image selection: Issue #17615 Docs you may find helpful: - Logging: Prefect logging concepts - Settings: Prefect settings If you can share the Prefect version and a bit more context on where the freeze happens, I can help pinpoint next steps (e.g., capturing a thread dump, checking for blocking I/O, or narrowing to a specific task).
g
@Marvin Above is the following function that generates the error. It is called by tasks and i want to raise the error to the tasks so its status should be error: async def fetch_many_jsons( urls: list[str], task: str, lote_id: int, out_dir: str | Path | None = None, limit: int = 10, timeout: float = 30.0, max_retries: int = 10, follow_pagination: bool = False, logger: Any | None = None, validate_results: bool = False, ) -> list[str] | list[dict]: """ - Se out_dir for fornecido, salva cada JSON em um arquivo e retorna a lista de caminhos - Caso contrårio, retorna a lista de dicionårios em memória """ logger = logger or get_prefect_logger_or_none() def log(msg: str): if logger: logger.warning(msg) else: print(msg) if task == "extract_detalhes_processos_senado": for i in range(1, 11): urls[i] = urls[i] + "ERRO" out_dir = ensure_dir(out_dir) if out_dir else None async def worker( queue: asyncio.Queue, results: list[dict], processed_urls: set, semaphore: asyncio.Semaphore, client: httpx.AsyncClient, stats: dict, task: str, lote_id: int, ): while True: # MantÊm o consumidor da fila vivo para processar outras urls url = await queue.get() if url in processed_urls: queue.task_done() continue # Adiciona logo em processed_urls para evitar que o queue pegue essa url processed_urls.add(url) async with semaphore: print(f"Baixando URL: {url=}") status_code = None request_message = None for attempt in range(max_retries): try: response = await client.get(url, timeout=timeout) status_code = response.status_code if status_code >= 400: try: error_response = response.json() if error_response.get("detail", None): request_message = error_response.get("detail", None) else: request_message = response.json() except Exception: request_message = response.text response.raise_for_status() data = response.json() if is_first_page(url): total_items = response.headers.get("x-total-count", None) if total_items: stats["total_items"] += int(total_items) if out_dir: raise Exception("O BLOCO out_dir ESTÁ COMENTADO") # name = hashlib.sha1(url.encode()).hexdigest() + ".json" # path = Path(out_dir) / name # # to_thread Ê usado para evitar que a escrita no disco congele o processo na rede # await asyncio.to_thread(save_json, path, data) # results.append(str(path)) else: results.append(data) # Se tiver paginação, adiciona novas URLs à fila if follow_pagination and "links" in data: links = { link["rel"]: link["href"] for link in data["links"] } if "self" in links and "last" in links: for new_url in generate_pages_urls( links["self"], links["last"] ): if new_url not in processed_urls: await queue.put(new_url) queue.task_done() break except Exception as e: if attempt < max_retries - 1: log( f"Um erro ocorreu no fetch de dados: {e}. TENTANDO NOVAMENTE. Tentativa: {attempt}" ) await asyncio.sleep(2**attempt) else: queue.task_done() message = f"Falha permanente ao baixar {url} após {max_retries} tentativas: {e}" log(message) try: insert_extract_error_db( lote_id=lote_id, task=task, status_code=status_code, message=request_message, url=url, ) except DatabaseError: raise ## Não deve levantar o erro. Lidaremos com as URLs problemåticas separadamente # raise Exception(message) queue = asyncio.Queue() for u in urls: await queue.put(u) processed_urls = set() results = [] stats = {"total_items": 0} semaphore = asyncio.Semaphore(limit) async with httpx.AsyncClient(headers=headers) as client: workers = [ asyncio.create_task( worker( queue, results, processed_urls, semaphore, client, stats, task, lote_id, ) ) for _ in range( int(limit) ) # Cria um pouco mais de workers do que conexþes abertas simultâneas ] await queue.join() for w in workers: w.cancel() if validate_results: validate( results=results, urls=urls, stats=stats, log=log, paginated=follow_pagination, ) return results
m
Your message was too long, here's your message at the allowed limit: Above is the following function that generates the error It is called by tasks and i want to raise the error to the tasks so its status should be error async def fetch_many_jsons( urls list[str] task str lote_id int out_dir str | Path | None = None limit int = 10 timeout float = 30 0 max_retries int = 10 follow_pagination bool = False logger Any | None = None validate_results bool = False ) -> list[str] | list[dict] - Se out_dir for fornecido salva cada JSON em um arquivo e retorna a lista de caminhos - Caso contrårio retorna a lista de dicionårios em memória logger = logger or get_prefect_logger_or_none() def log(msg str) if logger logger warning(msg) else print(msg) if task == extract_detalhes_processos_senado for i in range(1 11) urls[i] = urls[i] + ERRO out_dir = ensure_dir(out_dir) if out_dir else None async def worker( queue asyncio Queue results list[dict] processed_urls set semaphore asyncio Semaphore client httpx AsyncClient stats dict task str lote_id int ) while True # MantÊm o consumidor da fila vivo para processar outras urls url = await queue get() if url in processed_urls queue task_done() continue # Adiciona logo em processed_urls para evitar que o queue pegue essa url processed_urls add(url) async with semaphore print(f Baixando URL {url=} ) status_code = None request_message = None for attempt in range(max_retries) try response = await client get(url timeout=timeout) status_code = response status_code if status_code >= 400 try error_response = response json() if error_response get( detail None) request_message = error_response get( detail None) else request_message = response json() except Exception request_message = response text response raise_for_status() data = response json() if is_first_page(url) total_items = response headers get( x-total-count None) if total_items stats[ total_items ] += int(total_items) if out_dir raise Exception( O BLOCO out_dir ESTÁ COMENTADO ) # name = hashlib sha1(url encode()) hexdigest() + json # path = Path(out_dir) / name # # to_thread Ê usado para evitar que a escrita no disco congele o processo na rede # await <http //asyncio to|asyncio to>_thread(save_json path data) # results append(str(path)) else results append(data) # Se tiver paginação adiciona novas URLs à fila if follow_pagination and links in data links = { link[ rel ] link[ href ] for link in data[ links ] } if self in links and last in links for new_url in generate_pages_urls( links[ self ] links[ last ] ) if new_url not in processed_urls await queue put(new_url) queue task_done() break except Exception as e if attempt < max_retries - 1 log( f Um erro ocorreu no fetch de dados {e} TENTANDO NOVAMENTE Tentativa {attempt} ) await asyncio sleep(2**attempt) else queue task_done() message = f Falha permanente ao baixar {url} após {max_retries} tentativas {e} log(message) try insert_extract_error_db( lote_id=lote_id task=task status_code=status_code message=request_message url=url ) except DatabaseError raise ## Não deve levantar o erro Lidaremos com as URLs problemåticas separadamente # raise Exception(message) queue = asyncio Queue() for u in urls await queue put(u) processed_urls = set() results = [] stats = { total_items 0} semaphore = asyncio Semaphore(limit) async
g
@Marvin Above is the following function that generates the error. It is called by tasks and i want to raise the error to the tasks so its status should be error. Again, i want the task status to be error if the database throws an error: async def fetch_many_jsons( urls: list[str], task: str, lote_id: int, ... ) -> list[str] | list[dict]: if task == "extract_detalhes_processos_senado": for i in range(1, 11): urls[i] = urls[i] + "ERRO" async def worker( queue: asyncio.Queue, results: list[dict], processed_urls: set, semaphore: asyncio.Semaphore, client: httpx.AsyncClient, stats: dict, task: str, lote_id: int, ): while True: # MantÊm o consumidor da fila vivo para processar outras urls url = await queue.get() if url in processed_urls: queue.task_done() continue # Adiciona logo em processed_urls para evitar que o queue pegue essa url processed_urls.add(url) async with semaphore: print(f"Baixando URL: {url=}") status_code = None request_message = None for attempt in range(max_retries): try: response = await client.get(url, timeout=timeout) status_code = response.status_code if status_code >= 400: try: error_response = response.json() if error_response.get("detail", None): request_message = error_response.get("detail", None) else: request_message = response.json() except Exception: request_message = response.text response.raise_for_status() data = response.json() if is_first_page(url): total_items = response.headers.get("x-total-count", None) if total_items: stats["total_items"] += int(total_items) if out_dir: raise Exception("O BLOCO out_dir ESTÁ COMENTADO") else: results.append(data) queue.task_done() break except Exception as e: if attempt < max_retries - 1: await asyncio.sleep(2**attempt) else: queue.task_done() message = f"Falha permanente ao baixar {url} após {max_retries} tentativas: {e}" log(message) try: insert_extract_error_db( lote_id=lote_id, task=task, status_code=status_code, message=request_message, url=url, ) except DatabaseError: raise queue = asyncio.Queue() for u in urls: await queue.put(u) processed_urls = set() results = [] stats = {"total_items": 0} semaphore = asyncio.Semaphore(limit) async with httpx.AsyncClient(headers=headers) as client: workers = [ asyncio.create_task( worker( queue, results, processed_urls, semaphore, client, stats, task, lote_id, ) ) for _ in range( int(limit) ) # Cria um pouco mais de workers do que conexþes abertas simultâneas ] await queue.join() for w in workers: w.cancel() return results
m
✅ thought for 81.6 seconds
Got it — you want the Prefect task to be marked as error only when the database logging step (
insert_extract_error_db
) fails. Right now you’re raising inside a background worker task that you never await (you cancel workers after
queue.join()
), so that exception never reaches the caller. Prefect will only mark the task as failed if an exception escapes the task function. Two key fixes: 1) Don’t cancel and drop worker tasks — await them so exceptions propagate. 2) Use a clean shutdown pattern (sentinel values or TaskGroup) so workers exit and any exception (e.g., DatabaseError) bubbles up. Here’s a revised version using Python 3.13’s asyncio.TaskGroup that: - Fails fast and propagates DatabaseError to the caller (so your Prefect task will be FAILED) - Continues to swallow non-DB HTTP failures if that’s your desired behavior - Uses sentinels for clean worker shutdown - Ensures each queue item is marked done exactly once ``` import asyncio import httpx # Sentinel for graceful worker shutdown _STOP = object() async def fetch_many_jsons( urls: list[str], task: str, lote_id: int, *, limit: int = 10, headers: dict | None = None, timeout: float = 30.0, max_retries: int = 3, out_dir: str | None = None, log=print, # or your logger is_first_page=lambda u: False, insert_extract_error_db=None, # inject your DB fn DatabaseError=Exception, # inject your DB error type ) -> list[str] | list[dict]: if task == "extract_detalhes_processos_senado": # NOTE: guard against index out of range if urls has fewer than 11 items for i in range(min(10, len(urls))): urls[i] = urls[i] + "ERRO" queue: asyncio.Queue = asyncio.Queue() for u in urls: await queue.put(u) # enqueue one sentinel per worker for _ in range(limit): await queue.put(_STOP) processed_urls: set[str] = set() results: list[dict] = [] stats = {"total_items": 0} semaphore = asyncio.Semaphore(limit) async def worker(): nonlocal results, processed_urls, stats async with httpx.AsyncClient(headers=headers) as client: while True: url = await queue.get() try: if url is _STOP: # graceful shutdown return if url in processed_urls: continue processed_urls.add(url) async with semaphore: status_code = None request_message = None for attempt in range(max_retries): try: print(f"Baixando URL: {url=}") response = await client.get(url, timeout=timeout) status_code = response.status_code if status_code >= 400: try: error_response = response.json() request_message = error_response.get("detail", error_response) except Exception: request_message = response.text response.raise_for_status() data = response.json() if is_first_page(url): total_items = response.headers.get("x-total-count") if total_items: stats["total_items"] += int(total_items) if out_dir: # keep your original behavior if needed raise Exception("O BLOCO out_dir ESTÁ COMENTADO") else:
results.append(data) break # success, stop retry loop except Exception as e: # retry with backoff unless last attempt if attempt < max_retries - 1: await asyncio.sleep(2**attempt) else: message = f"Falha permanente ao baixar {url} apĂłs {max_retries} tentativas: {e}" log(message) # Log to DB; if DB fails, raise so Prefect marks task as FAILED if insert_extract_error_db is not None: try: insert_extract_error_db( lote_id=lote_id, task=task, status_code=status_code, message=request_message, url=url, ) except DatabaseError as db_exc: # This propagates out of the worker and TaskGroup, # cancelling other workers and failing the Prefect task. raise db_exc # If DB log succeeded, we swallow the HTTP error per your requirement finally: # Ensure every queued item (including sentinel) is marked done exactly once queue.task_done() # Start workers and let exceptions propagate async with asyncio.TaskGroup() as tg: for _ in range(limit): tg.create_task(worker()) # Wait until all items are processed (including sentinels) await queue.join() # Exiting TaskGroup awaits all workers; any unhandled exception (e.g. DatabaseError) will # cancel the rest and be re-raised here. return results
Copy code
How this makes the Prefect task fail as desired
- If `insert_extract_error_db(...)` raises `DatabaseError`, that exception will bubble out of a worker, the TaskGroup will cancel the other workers, and fetch_many_jsons will raise. If you’re awaiting this from a Prefect task, the task will be marked FAILED with the database error.
- HTTP/download failures that do not trigger a DB error are swallowed (as you had), so the Prefect task will not fail just for those.

Using it from a Prefect task (3.x)
from prefect import task, flow @task async def run_fetch(...): return await fetch_many_jsons(...) @flow async def my_flow(...): data = await run_fetch(...) # ... ``` If you prefer not to use TaskGroup - Replace TaskGroup with
workers = [asyncio.create_task(worker()) for _ in range(limit)]
, then after
await queue.join()
, do
await asyncio.gather(*workers)
to propagate exceptions. The sentinel shutdown pattern is still required; don’t just
cancel()
the workers, or you’ll drop exceptions. References - Tasks and failure semantics: https://docs.prefect.io/latest/concepts/tasks - States and failures: https://docs.prefect.io/latest/concepts/states If you want the task to fail on any permanent HTTP error as well, just re-raise the final exception after logging (instead of swallowing it). I can show that variant too if that’s what you need.
g
@Marvin NO, this is too complex. Isn't there a way, maybe the prefect api, where i can call an exception to the task from the function that a task called?
m
✅ thought for 143.3 seconds
Yes — keep it simple. In Prefect 3.x, just raise a normal Python exception from the helper function. It will bubble up and the task will be marked Failed automatically. Example:
Copy code
from prefect import task, flow

def helper():
    # Anything you raise here will fail the calling task
    raise RuntimeError("Helper failed")

@task
def my_task():
    helper()  # Exception propagates, task fails
    return "won't reach here"

@flow
def my_flow():
    my_task()

my_flow()
If you prefer not to raise, you can also return a Failed state from the task:
Copy code
from prefect import task
from prefect.states import Failed

@task
def my_task():
    if some_condition:
        return Failed(message="Custom failure reason")
You generally don’t need to call the Prefect API to do this; exceptions are the intended, simplest path. If you really want to set state manually, we can show that too, but it’s rarely necessary.
g
@Marvin I want to configure a sink for the logs
m
✅ thought for 227.2 seconds
Got it! In Prefect 3.x you can add custom “sinks” (handlers) to send logs to files, JSON, or external systems using standard Python logging config. A couple quick questions to tailor this: - Which sink do you want: file, JSON file, Datadog/Splunk/Sentry/CloudWatch, etc.? - Do you want flow/task logs, worker logs, or both? - Should logs still go to the Prefect UI/API as well? If you just want to write flow/task logs to a file (and still send them to the Prefect UI), drop this logging.yml in place and restart your flows/workers:
Copy code
version: 1
disable_existing_loggers: false

formatters:
  standard:
    (): prefect.logging.formatters.PrefectFormatter
    format: "%(asctime)s | %(levelname)-7s | %(name)s - %(message)s"
  json:
    (): prefect.logging.formatters.JsonFormatter
    format: "default"   # or "pretty"

handlers:
  console:
    class: prefect.logging.handlers.PrefectConsoleHandler
    formatter: standard
    stream: <ext://sys.stderr>
    level: INFO

  api:
    class: prefect.logging.handlers.APILogHandler
    level: NOTSET

  file_text:
    class: logging.handlers.RotatingFileHandler
    formatter: standard
    filename: /var/log/prefect/flows.log
    maxBytes: 10485760
    backupCount: 5
    level: DEBUG

  file_json:
    class: logging.handlers.RotatingFileHandler
    formatter: json
    filename: /var/log/prefect/flows.json
    maxBytes: 10485760
    backupCount: 5
    level: INFO

loggers:
  prefect.flow_runs:
    level: NOTSET
    handlers: [console, file_text, file_json, api]
    propagate: false

  prefect.task_runs:
    level: NOTSET
    handlers: [console, file_text, file_json, api]
    propagate: false

  # Add this if you want worker process logs too:
  prefect.workers:
    level: INFO
    handlers: [console, file_text]
    propagate: false

root:
  level: WARNING
  handlers: [console]
How to activate: - Save as ~/.prefect/logging.yml (auto-loaded), or - Point Prefect at a custom path: - export PREFECT_LOGGING_SETTINGS_PATH=/path/to/logging.yml Notes and options: - Keep logs in the Prefect UI/API: leave the
api
handler; or disable globally with
export PREFECT_LOGGING_TO_API_ENABLED=false
. - JSON logs: use the built-in
prefect.logging.formatters.JsonFormatter
as shown. - Capture print() calls as logs: set
@flow(log_prints=True)
or
export PREFECT_LOGGING_LOG_PRINTS=true
. - In containers/Kubernetes: ensure the log directory exists and is writable (e.g., mount a volume at /var/log/prefect). Programmatic (quick add a file sink in code):
Copy code
import logging
from prefect import flow
from prefect.logging import get_run_logger

# Add a file sink for flow logs at runtime
file_handler = logging.FileHandler("/tmp/my_flow.log")
file_handler.setFormatter(logging.Formatter(
    "%(asctime)s | %(levelname)s | %(name)s - %(message)s"
))
logging.getLogger("prefect.flow_runs").addHandler(file_handler)

@flow
def my_flow():
    logger = get_run_logger()
    <http://logger.info|logger.info>("This will go to the file and the Prefect API (if enabled).")

my_flow()
External systems (Datadog/Splunk/Sentry/etc.): - Use their Python logging handler or a small custom handler, then reference it in logging.yml:
Copy code
handlers:
  datadog:
    class: my_pkg.logging.DatadogLogHandler
    level: INFO
    api_key: ${DD_API_KEY}

loggers:
  prefect.flow_runs:
    handlers: [datadog, api]
    propagate: false
Tell me the sink you want (and where you’re running: local, Docker, K8s), and I’ll tailor the exact config for you.
g
I want the logs to be on a txt file and i want the logs from both flows and tasks. I want it simple, i dont want to alter their format, just put the logs on the file
@Marvin I want the logs to be on a txt file and i want the logs from both flows and tasks. I want it simple, i dont want to alter their format, just put the logs on the file
m
✅ thought for 33.7 seconds
Great — here’s the simplest way to write both flow and task logs to a plain text file in Prefect 3.x without changing their format. 1) Create a logging config file Save this as ~/.prefect/logging.yml (or any path you prefer):
Copy code
version: 1
disable_existing_loggers: false

formatters:
  default:
    (): prefect.logging.formatters.PrefectFormatter

handlers:
  file:
    class: logging.FileHandler
    formatter: default
    filename: /path/to/prefect.log   # make sure this directory exists
    mode: a
    level: NOTSET

  # Keep sending logs to Prefect UI/API too; remove this handler if you want file-only
  api:
    class: prefect.logging.handlers.APILogHandler
    level: NOTSET

loggers:
  prefect.flow_runs:
    level: NOTSET
    handlers: [file, api]
    propagate: false

  prefect.task_runs:
    level: NOTSET
    handlers: [file, api]
    propagate: false
2) Activate it - Default: Prefect auto-loads ~/.prefect/logging.yml - Or point to a custom path:
Copy code
export PREFECT_LOGGING_SETTINGS_PATH=/path/to/logging.yml
That’s it — flow and task logs will now also be written to the file with the same Prefect formatting you’re used to. If you want file-only logging, just remove the
api
handler from the logger handler lists. Optional: quick in-code approach
Copy code
import logging
from prefect import flow
from prefect.logging import get_run_logger
from prefect.logging.formatters import PrefectFormatter

# One-time setup (e.g., at app startup)
file_handler = logging.FileHandler("/path/to/prefect.log", mode="a")
file_handler.setFormatter(PrefectFormatter())
logging.getLogger("prefect.flow_runs").addHandler(file_handler)
logging.getLogger("prefect.task_runs").addHandler(file_handler)

@flow
def demo():
    logger = get_run_logger()
    <http://logger.info|logger.info>("This goes to the file (and Prefect UI if API logging is enabled).")

demo()
Want me to set this up for a specific path or environment (Docker/Kubernetes)?
g
@Marvin Ok lets go again about this log. I want to log only the logs level error and above, i want to set a number of days where the logs are cleared. Also i want to customize the name of the log. And i want to be able to customize the place where the logs are going to live
m
✅ thought for 207.5 seconds
Perfect — here’s a simple, robust setup that writes only ERROR-and-above logs from both flows and tasks to a file, rotates the file daily, and keeps N days of history. You can also easily customize the file name and location. 1) Create a logging config Save this as ~/.prefect/logging.yml (or any path you prefer):
Copy code
version: 1
disable_existing_loggers: false

formatters:
  default:
    (): prefect.logging.formatters.PrefectFormatter  # keep Prefect's default formatting

handlers:
  errors_file:
    class: logging.handlers.TimedRotatingFileHandler
    formatter: default
    level: ERROR
    filename: ${PREFECT_HOME}/logs/prefect_errors.log   # customize path/name
    when: midnight       # rotate daily
    interval: 1
    backupCount: 7       # keep last 7 days
    encoding: utf-8
    delay: false

loggers:
  prefect.flow_runs:
    level: NOTSET
    handlers: [errors_file]
    propagate: false

  prefect.task_runs:
    level: NOTSET
    handlers: [errors_file]
    propagate: false

root:
  level: WARNING
Notes: - Level is enforced on the handler (ERROR and above only goes to the file). - Daily rotation at local midnight;
backupCount
controls how many days you keep. - The format is unchanged (uses Prefect’s default PrefectFormatter). - Make sure the directory in
filename
exists and is writable. 2) Activate it - Default path: Prefect auto-loads ~/.prefect/logging.yml - Or point to your file explicitly:
Copy code
export PREFECT_LOGGING_SETTINGS_PATH=/path/to/logging.yml
Customize without editing the file (environment overrides) You can change the file name/location and retention days at runtime with env vars that map to the handler fields: - Change file path/name:
Copy code
export PREFECT_LOGGING_HANDLERS_ERRORS_FILE_FILENAME=/var/log/prefect/my_custom_name.log
- Change retention days:
Copy code
export PREFECT_LOGGING_HANDLERS_ERRORS_FILE_BACKUPCOUNT=14
- Change min level (e.g., CRITICAL only):
Copy code
export PREFECT_LOGGING_HANDLERS_ERRORS_FILE_LEVEL=CRITICAL
Optional - Keep sending logs to Prefect UI/API as well (errors only) by adding the
api
handler and setting its level to ERROR:
Copy code
handlers:
  api:
    class: prefect.logging.handlers.APILogHandler
    level: ERROR

loggers:
  prefect.flow_runs:
    handlers: [errors_file, api]
  prefect.task_runs:
    handlers: [errors_file, api]
If you want a specific directory or file name, tell me the exact path and retention days you want and I’ll tailor the config to match.
g
@Marvin 2) Activate it - Default path: Prefect auto-loads ~/.prefect/logging.yml - Or point to your file explicitly:
Copy code
export PREFECT_LOGGING_SETTINGS_PATH=/path/to/logging.yml
Where shoud i import this? I have put the file in ./src/config/logging.yml
m
✅ thought for 172.0 seconds
You don’t import the logging.yml in code. Prefect loads it automatically at startup if you point the setting to your file. Since your file is at ./src/config/logging.yml, set the path via an environment variable (or profile) in the environment where your flows/workers run. Pick one of these: - One-off in your shell (recommended for local runs)
Copy code
export PREFECT_LOGGING_SETTINGS_PATH="$(pwd)/src/config/logging.yml"
python your_flow.py
- Persist for your current Prefect profile
Copy code
prefect config set PREFECT_LOGGING_SETTINGS_PATH="$(pwd)/src/config/logging.yml"
- Windows (PowerShell)
Copy code
$env:PREFECT_LOGGING_SETTINGS_PATH = "$PWD/src/config/logging.yml"
python your_flow.py
- In code (only if you must), set it BEFORE importing Prefect
Copy code
import os
os.environ["PREFECT_LOGGING_SETTINGS_PATH"] = "/abs/path/to/src/config/logging.yml"

from prefect import flow  # import AFTER setting the env var
- In Docker/Kubernetes/Workers - Set the env var in your Dockerfile, Compose, K8s manifest, or on the work pool/worker’s environment so it’s present when the worker process starts. Notes: - Use an absolute path. - The file must exist in the runtime environment (e.g., inside the container if you’re containerized). - You can also use the alias
PREFECT_LOGGING_CONFIG_PATH
if you prefer. Docs: Customize logging
g
@Marvin does logs get stored in the prefect database?
m
✅ thought for 32.1 seconds
Yes. By default in Prefect 3.x, flow and task logs are sent to the Prefect API and persisted in the API database (Prefect Cloud or a self-hosted Prefect Server). That’s why you can see them in the UI on each run. - Controlled by:
PREFECT_LOGGING_TO_API_ENABLED
(defaults to true). Set to
false
or remove the
APILogHandler
from your logging.yml if you do not want logs stored in the Prefect database. - What’s stored: logs from
prefect.flow_runs
,
prefect.task_runs
, and any extra loggers you’ve attached via config. - Worker logs: can also be sent to the API (and stored) when using
WorkerAPILogHandler
and the worker has a backend_id; otherwise they stay in the worker’s stdout/stderr unless you add your own file handler. - Retention: - Prefect Cloud: retained according to your workspace’s retention settings/plan. - Self-hosted Server: retained as long as your database keeps them (you control pruning/retention). Docs for configuring logging: Customize logging
g
@Marvin forget all that i told you. I just want to know how to store the logs error level and above on a file
m
✅ thought for 23.6 seconds
Simplest way: add a file handler at ERROR level for flow and task logs. Option A — config file (recommended) 1) Save this as ~/.prefect/logging.yml (or anywhere you like):
Copy code
version: 1
disable_existing_loggers: false

formatters:
  default:
    (): prefect.logging.formatters.PrefectFormatter

handlers:
  errors_file:
    class: logging.FileHandler
    formatter: default
    level: ERROR
    filename: /path/to/prefect_errors.log   # make sure this directory exists
    mode: a
    encoding: utf-8

loggers:
  prefect.flow_runs:
    level: NOTSET
    handlers: [errors_file]
    propagate: false

  prefect.task_runs:
    level: NOTSET
    handlers: [errors_file]
    propagate: false
2) Activate it: - If saved to ~/.prefect/logging.yml, Prefect will auto-load it. - Or point Prefect to your file:
Copy code
export PREFECT_LOGGING_SETTINGS_PATH=/abs/path/to/logging.yml
Option B — quick in-code setup Run this once at startup (before your flows run):
Copy code
import logging
from prefect.logging.formatters import PrefectFormatter

handler = logging.FileHandler("/path/to/prefect_errors.log", mode="a", encoding="utf-8")
handler.setLevel(logging.ERROR)
handler.setFormatter(PrefectFormatter())

logging.getLogger("prefect.flow_runs").addHandler(handler)
logging.getLogger("prefect.task_runs").addHandler(handler)
Notes: - This captures only ERROR and above to the file. - If you also want logs in the Prefect UI, add the
api
handler to those loggers as well. - Docs: Customize logging
g
@Marvin ~/.prefect/logging.yml created here run the deployment but the log wasnt stored
m
✅ thought for 47.4 seconds
Got it — if the file didn’t appear, it’s usually one of these: - Prefect didn’t load your logging.yml (different runtime/env than your shell, e.g., worker, Docker, K8s) - The filename path doesn’t exist or isn’t writable - No ERROR-level logs were actually emitted - The handler wasn’t attached to the flow/task loggers Quickest way to verify and fix: 1) Point Prefect explicitly to your config (absolute path) in the same environment that runs the flow/worker, then restart that process
Copy code
export PREFECT_LOGGING_SETTINGS_PATH=/abs/path/to/src/config/logging.yml
# restart your worker/process/container, then run the deployment again
2) Use a known-good minimal config and an absolute, writable path
Copy code
version: 1
disable_existing_loggers: false

formatters:
  default:
    (): prefect.logging.formatters.PrefectFormatter

handlers:
  errors_file:
    class: logging.FileHandler
    formatter: default
    level: ERROR
    filename: /tmp/prefect_errors.log
    mode: a
    encoding: utf-8

loggers:
  prefect.flow_runs:
    level: NOTSET
    handlers: [errors_file]
    propagate: false

  prefect.task_runs:
    level: NOTSET
    handlers: [errors_file]
    propagate: false
3) Sanity-test that ERROR logs are produced - Trigger an error or log one explicitly:
Copy code
from prefect import flow, task
from prefect.logging import get_run_logger

@task
def t():
    get_run_logger().error("Test task ERROR")
    raise RuntimeError("boom")

@flow
def f():
    get_run_logger().error("Test flow ERROR")
    try:
        t()
    except Exception:
        pass

f()
- Then check /tmp/prefect_errors.log If you’re running this as a deployment on a worker: - Ensure the logging.yml is present inside the worker’s runtime (container/VM), not just on your dev machine - Set PREFECT_LOGGING_SETTINGS_PATH in the worker’s env (Dockerfile/compose/K8s manifest/work-pool env) - Use a filename that exists and is writable in that runtime (e.g., mount a volume for /var/log/prefect) - Restart the worker so it picks up the config If it still doesn’t write, share: - Where the deployment runs (local subprocess, Docker, Kubernetes) - Your exact logging.yml - The absolute target path you expect the file to appear Docs for reference: Customize logging
g
@Marvin uv run prefect deployment run 'Pipeline Flow/prisma-do-congresso-pipeline' i run my deploy with this. How to pass parameters to the deploy?
m
✅ thought for 88.7 seconds
You can pass flow parameters to a deployment run with either repeated
-p/--param
flags or a single
--params
JSON object. - Individual params (repeat
-p/--param
). Values are parsed as JSON:
Copy code
uv run prefect deployment run 'Pipeline Flow/prisma-do-congresso-pipeline' \
  -p customer_id=1234 \
  -p date="2026-02-01" \
  -p enabled=true \
  -p items='[1, 2, 3]' \
  -p config='{"timeout": 30}'
- All params as one JSON object with `--params`:
Copy code
uv run prefect deployment run 'Pipeline Flow/prisma-do-congresso-pipeline' \
  --params '{"customer_id":1234,"date":"2026-02-01","enabled":true,"items":[1,2,3]}'
- From a file via stdin (no
--params-file
, so use
--params -
):
Copy code
cat params.json | uv run prefect deployment run 'Pipeline Flow/prisma-do-congresso-pipeline' --params -
# or
uv run prefect deployment run 'Pipeline Flow/prisma-do-congresso-pipeline' --params - < params.json
Notes: - Values are interpreted as JSON; quote strings and wrap lists/objects in single quotes to avoid shell parsing. - If you use both
--params
and
-p
, the individual
-p
flags override keys in
--params
. - You can add
--watch
to wait for completion and
--flow-run-name
to customize the run name. Want to share the parameters you need to pass? I can help craft the exact command (including any tricky quoting).
g
@Marvin uv run prefect flow-run logs '0c4b12c1-2b07-4621-a845-eca0d9ae5265' > flow.log thiis command gives me the logs of the flow. But what if i want all the logs from all flows and tasks?
m
✅ thought for 423.6 seconds
Short answer: - For a single flow run,
prefect flow-run logs <FLOW_RUN_ID>
already includes both flow- and task-run logs for that run. - There isn’t a single CLI command to dump “all logs from all runs” at once. You’ll need to loop over runs (CLI) or use the API/Client to pull logs across runs. Options: 1) CLI loop over flow runs and append logs
Copy code
# Grab recent N flow runs and append their logs to one file
uv run prefect flow-run ls --limit 500 -o json | jq -r '.[].id' | while read id; do
  echo "=== Logs for flow run $id ==="
  uv run prefect flow-run logs "$id"
  echo
done > all_logs.txt
2) REST download endpoint per run (CSV), loop similarly - There’s a download endpoint per flow run that returns CSV: Flow run logs download API - Example (replace BASE_URL and auth as needed):
Copy code
for id in $(uv run prefect flow-run ls --limit 500 -o json | jq -r '.[].id'); do
  curl -s -H "Authorization: Bearer $PREFECT_API_KEY" \
    "$BASE_URL/api/flow_runs/$id/logs/download" >> all_logs.csv
done
3) Programmatic: fetch logs across many runs (with filters)
Copy code
# Fetch logs across all runs (optionally filter by time/level) and write CSV
import asyncio, csv
from datetime import datetime, timedelta, timezone
from prefect.client.orchestration import get_client
from prefect.client.schemas.filters import LogFilter, LogFilterTimestamp, LogFilterLevel

async def main():
    async with get_client() as client:
        limit = 1000
        offset = 0
        # Adjust filters as needed; remove 'timestamp=' to fetch everything (may be large)
        lf = LogFilter(
            timestamp=LogFilterTimestamp(after_=datetime.now(timezone.utc) - timedelta(days=1)),
            level=LogFilterLevel(ge_=10)  # >= DEBUG(10), INFO(20), etc.
        )
        with open("all_logs.csv", "w", newline="") as f:
            w = csv.writer(f)
            w.writerow(["timestamp","level","flow_run_id","task_run_id","message"])
            while True:
                batch = await client.read_logs(limit=limit, offset=offset, logs=lf)
                if not batch:
                    break
                for l in batch:
                    w.writerow([l.timestamp.isoformat(), l.level, l.flow_run_id, l.task_run_id, l.message])
                offset += len(batch)

asyncio.run(main())
Notes: - The single-run command already aggregates task logs under that flow run. - There’s no built-in CLI to stream or export logs across all runs in one shot; use a loop or the client/API. - CLI refs: - flow-run logs source (includes task logs via flow_run_id filter) If you tell me how far back you want to go (e.g., last 24h) and your environment (Cloud vs. self-hosted), I can tailor the exact command/script (including auth/base URL if using the REST endpoint).
g
@Marvin
Copy code
# Grab recent N flow runs and append their logs to one file
uv run prefect flow-run ls --limit 500 -o json | jq -r '.[].id' | while read id; do
  echo "=== Logs for flow run $id ==="
  uv run prefect flow-run logs "$id"
  echo
done > all_logs.txt
This seems to work. But how can i log only levels warning and above?
m
✅ thought for 59.8 seconds
Short answer: the CLI doesn’t have a level filter for
prefect flow-run logs
. You’ve got two practical options: 1) Keep your CLI loop and filter by level names with grep
Copy code
# WARNING, ERROR, CRITICAL only
uv run prefect flow-run ls --limit 500 -o json | jq -r '.[].id' | while read id; do
  echo "=== Logs for flow run $id ==="
  uv run prefect flow-run logs "$id" | grep -E '\b(WARNING|ERROR|CRITICAL)\b'
  echo
done > warn_plus.log
2) Use the API/Client with a proper level filter (recommended for accuracy and scale) - Levels: DEBUG=10, INFO=20, WARNING=30, ERROR=40, CRITICAL=50
Copy code
# Fetch WARNING+ across all runs (optionally add a time window)
import asyncio, csv
from datetime import datetime, timedelta, timezone
from prefect.client.orchestration import get_client
from prefect.client.schemas.filters import LogFilter, LogFilterLevel, LogFilterTimestamp

async def main():
    async with get_client() as client:
        limit, offset = 1000, 0
        lf = LogFilter(
            level=LogFilterLevel(ge_=30),  # WARNING and above
            # Optional time window to constrain size:
            # timestamp=LogFilterTimestamp(after_=datetime.now(timezone.utc) - timedelta(days=1))
        )
        with open("warn_plus.csv", "w", newline="") as f:
            w = csv.writer(f)
            w.writerow(["timestamp","level","flow_run_id","task_run_id","message"])
            while True:
                logs = await client.read_logs(limit=limit, offset=offset, logs=lf)
                if not logs:
                    break
                for l in logs:
                    w.writerow([l.timestamp.isoformat(), l.level, l.flow_run_id, l.task_run_id, l.message])
                offset += len(logs)

asyncio.run(main())
Bonus (per-run CSV via REST, then filter): - There’s a CSV download endpoint per flow run: Flow run logs download API - You can loop over run IDs and post-filter by level. If you do this, use a CSV-aware tool (e.g., csvkit) to avoid issues with commas in messages:
Copy code
for id in $(uv run prefect flow-run ls --limit 500 -o json | jq -r '.[].id'); do
  curl -s -H "Authorization: Bearer $PREFECT_API_KEY" \
    "$BASE_URL/api/flow_runs/$id/logs/download" \
    | csvgrep -c level -r '^(3[0-9]|4[0-9]|50)$'
done > warn_plus.csv
If you tell me your environment (Cloud vs self-hosted) and desired time window, I can tailor the exact command/script (including BASE_URL/auth if using the REST endpoint).
g
@Marvin
Copy code
# WARNING, ERROR, CRITICAL only
uv run prefect flow-run ls --limit 500 -o json | jq -r '.[].id' | while read id; do
  echo "=== Logs for flow run $id ==="
  uv run prefect flow-run logs "$id" | grep -E '\b(WARNING|ERROR|CRITICAL)\b'
  echo
done > warn_plus.log
what is thiis --limit flag?
m
✅ thought for 35.6 seconds
--limit
on
prefect flow-run ls
controls how many flow runs the command returns. By default it returns 15; setting
--limit 500
gets the 500 most recent flow runs. In your loop, that means you’ll fetch logs for up to 500 runs. Examples:
Copy code
# 1000 most recent runs
uv run prefect flow-run ls --limit 1000 -o json
You can also narrow which runs you list:
Copy code
# Only runs for a specific flow name
uv run prefect flow-run ls --flow-name "Pipeline Flow" --limit 500 -o json

# Only COMPLETED and FAILED runs
uv run prefect flow-run ls --state COMPLETED --state FAILED --limit 500 -o json
Tip:
--limit
is about how many runs are listed, not how many logs are shown per run. To see all available options:
Copy code
uv run prefect flow-run ls --help
g
@Marvin I got a main flow that executes three tasks. Each one of these tasks executes a flow with many tasks. I want only to get the logs from the most recent execution
m
✅ thought for 344.6 seconds
Got it — you want logs for just the most recent “main” flow run, including the subflows it kicked off. Quick CLI (parent run only) - This grabs the latest run for your main flow name and prints its logs (includes task logs, but not subflows):
Copy code
LATEST=$(uv run prefect flow-run ls --flow-name "Main Flow" --limit 1 -o json | jq -r '.[0].id')
uv run prefect flow-run logs "$LATEST" > latest_parent.log
Include the 3 subflows from that run (simple one-level) - If each of the 3 tasks launches exactly one subflow, you can fetch those child flow-run IDs via the API and append their logs:
Copy code
# Get latest parent run
LATEST=$(uv run prefect flow-run ls --flow-name "Main Flow" --limit 1 -o json | jq -r '.[0].id')

# BASE_URL/Auth for Prefect Cloud/Self-hosted (set these appropriately)
# export BASE_URL="<https://api.prefect.cloud/api/accounts/<acct>/workspaces/<ws>>"
# export PREFECT_API_KEY="<your-key>"

# Get direct child subflow run IDs (one level)
CHILDREN=$(curl -s -H "Authorization: Bearer $PREFECT_API_KEY" \
  -H "Content-Type: application/json" \
  -X POST "$BASE_URL/flow_runs/filter" \
  -d '{"flow_runs":{"parent_flow_run_id":{"any_":["'"$LATEST"'"]}},"limit":2000}' \
  | jq -r '.[].id')

# Write parent + child logs to one file
{
  echo "=== Parent $LATEST ==="
  uv run prefect flow-run logs "$LATEST"
  for id in $CHILDREN; do
    echo
    echo "=== Child $id ==="
    uv run prefect flow-run logs "$id"
  done
} > latest_execution.log
Programmatic (robust; handles any number of child subflows) - This script finds the latest run of your main flow by name, discovers all direct child subflows from that run, and exports all logs in time order (optionally filter WARNING+ by uncommenting the level filter).
Copy code
import asyncio, csv
from prefect.client.orchestration import get_client
from prefect.client.schemas.filters import FlowFilter, FlowFilterName, FlowRunFilter, LogFilter, LogFilterFlowRunId, LogFilterLevel
from prefect.client.schemas.sorting import FlowRunSort, LogSort

MAIN_FLOW_NAME = "Main Flow"  # change to your flow's name

async def main():
    async with get_client() as client:
        # Get latest parent run for this flow name
        parents = await client.read_flow_runs(
            flows=FlowFilter(name=FlowFilterName(any_=[MAIN_FLOW_NAME])),
            flow_runs=FlowRunFilter(),  # optionally restrict to root with parent_task_run_id.is_null_=True
            sort=FlowRunSort.START_TIME_DESC,
            limit=1,
        )
        if not parents:
            print("No runs found for flow:", MAIN_FLOW_NAME)
            return
        parent = parents[0]

        # Get direct child subflows of this run
        children = await client.read_flow_runs(
            flow_runs=FlowRunFilter(parent_flow_run_id={"any_":[parent.id]}),
            limit=2000
        )
        flow_run_ids = [parent.id] + [c.id for c in children]

        # Pull all logs for parent + children (optionally filter WARNING+)
        limit, offset = 1000, 0
        with open("latest_execution.csv", "w", newline="") as f:
            w = csv.writer(f)
            w.writerow(["timestamp","level","flow_run_id","task_run_id","message"])
            while True:
                batch = await client.read_logs(
                    limit=limit,
                    offset=offset,
                    logs=LogFilter(
                        flow_run_id=LogFilterFlowRunId(any_=flow_run_ids),
                        # level=LogFilterLevel(ge_=30),  # uncomment for WARNING+
                    ),
                    sort=LogSort.TIMESTAMP_ASC,
                )
                if not batch:
                    break
                for l in batch:
                    w.writerow([l.timestamp.isoformat(), l.level, l.flow_run_id, l.task_run_id, l.message])
                offset += len(batch)

asyncio.run(main())
Notes
-
prefect flow-run logs <id>
shows all logs for that specific flow run (including its tasks), but not logs from subflows. That’s why we enumerate child flow runs and collect their logs too. - If you prefer CSV per run via REST, there’s a download endpoint per flow run: Flow run logs download API. If you share your main flow’s exact name (or deployment name), I can tailor the exact one-liner or script for your setup.
g
@Marvin cant i get the flows ids from the deployment id of the run?
m
✅ thought for 97.9 seconds
Yes—you can get the most recent flow run ID(s) from a deployment ID. What’s possible: - Programmatic/API: filter flow runs by
deployment_id
and sort by start time to get the latest run. This is the most reliable way (and lets you then fetch logs). - CLI: there isn’t a
--deployment
filter on
prefect flow-run ls
, so you’ll need to use the API/Client or REST. As a looser workaround you can list by flow name, but that may mix runs from multiple deployments of the same flow. Python (recommended) — get latest run for a deployment and its logs
Copy code
import asyncio, csv
from prefect.client.orchestration import get_client
from prefect.client.schemas.filters import DeploymentFilter, DeploymentFilterId, LogFilter, LogFilterFlowRunId
from prefect.client.schemas.sorting import FlowRunSort, LogSort

DEPLOYMENT_ID = "your-deployment-uuid"

async def main():
    async with get_client() as client:
        # Find the most recent flow run for this deployment
        runs = await client.read_flow_runs(
            deployments=DeploymentFilter(id=DeploymentFilterId(any_=[DEPLOYMENT_ID])),
            sort=FlowRunSort.START_TIME_DESC,
            limit=1,
        )
        if not runs:
            print("No runs found for deployment:", DEPLOYMENT_ID)
            return

        latest_id = runs[0].id
        print("Latest flow run:", latest_id)

        # Fetch logs for that run
        logs = await client.read_logs(
            logs=LogFilter(flow_run_id=LogFilterFlowRunId(any_=[latest_id])),
            sort=LogSort.TIMESTAMP_ASC,
            limit=1000,
        )
        for l in logs:
            print(f"{l.timestamp} [{l.level}] {l.message}")

asyncio.run(main())
REST one-liner — get latest run ID for a deployment and print its logs
Copy code
# Set BASE_URL and PREFECT_API_KEY for your workspace
# BASE_URL example (Cloud): <https://api.prefect.cloud/api/accounts/<acct>/workspaces/<ws>>
LATEST=$(curl -s -H "Authorization: Bearer $PREFECT_API_KEY" \
  -H "Content-Type: application/json" \
  -X POST "$BASE_URL/flow_runs/filter" \
  -d '{"deployments":{"id":{"any_":["DEPLOYMENT_ID_HERE"]}},"sort":"START_TIME_DESC","limit":1}' \
  | jq -r '.[0].id')

uv run prefect flow-run logs "$LATEST"
If what you meant was “flow id” (the flow object behind the deployment), you can fetch it from the deployment: - Programmatic:
Copy code
from prefect.client.orchestration import get_client
from uuid import UUID

async def get_flow_id(deployment_id: str):
    async with get_client() as client:
        d = await client.read_deployment(UUID(deployment_id))
        print(d.flow_id)

# asyncio.run(get_flow_id("your-deployment-uuid"))
If you share your deployment ID or the “Flow/Deployment” name, I can tailor the exact command/script for your setup.
g
@Marvin show me trhough the cli siimply how to get the flows ids from the deployment id
m
✅ thought for 66.0 seconds
Yes — easiest via CLI is to list deployments as JSON and filter by the deployment ID: - Just the flow_id
Copy code
uv run prefect deployment ls -o json | jq -r '.[] | select(.id=="<DEPLOYMENT_ID>") | .flow_id'
- Flow details for that deployment (handy if you want to see more fields)
Copy code
uv run prefect deployment ls -o json | jq '.[] | select(.id=="<DEPLOYMENT_ID>")'
- Flow ID and flow name (if available in your output)
Copy code
uv run prefect deployment ls -o json | jq -r '.[] | select(.id=="<DEPLOYMENT_ID>") | "\(.flow_id) \(.flow_name)"'
Note: A deployment maps to exactly one flow, so you’ll get a single
flow_id
for a given
deployment_id
.
g
@Marvin Where are this logs stored in my project running locally?
m
✅ thought for 26.6 seconds
Short answer: they aren’t stored as files in your project directory. In Prefect 3.x: - If you’re using Prefect Cloud: logs are stored in Prefect Cloud’s database and fetched via the API/UI. - If you’re running a local Prefect Server (
prefect server start
): logs are stored in the server’s Postgres database (managed by Docker), and fetched via the API/CLI. They’re not written to flat files on disk by default. - If you run flows “standalone” without an API: logs go to stdout/stderr only unless you add your own file handler. Ways to get them locally: - Keep doing what you did: redirect CLI output to a file
Copy code
uv run prefect flow-run logs <FLOW_RUN_ID> > flow.log
- Use the REST CSV download per run (Cloud or self-hosted) and save to disk: Flow run logs download API If you want Prefect to also write logs to a local file automatically, add a file handler via a logging config (set
PREFECT_LOGGING_SETTINGS_PATH
to a YAML file) so logs go to both the API and a local file. If you tell me whether you’re on Cloud or
prefect server start
, I can share a ready-to-use config.
g
@Marvin These commands you gave me to see the flows inside a deployment gives me error when i run:
Copy code
uv run prefect deployment ls -o json | jq -r '.[] | select(.id=="<DEPLOYMENT_ID>") | .flow_id'
No such option: -o
m
✅ thought for 76.5 seconds
Good catch—your Prefect CLI likely doesn’t support the -o/--output flag for deployment ls. Can you share your Prefect version (
prefect --version
)? In newer 3.x,
--output json
is available; in older versions it isn’t. Here are simple CLI alternatives that don’t rely on -o: Option A: REST API (works in all versions) - Set BASE_URL and PREFECT_API_KEY (Cloud or self-hosted), then:
Copy code
DEPLOYMENT_ID=<your-deployment-uuid>

LATEST_FLOW_ID=$(curl -s \
  -H "Authorization: Bearer $PREFECT_API_KEY" \
  -H "Content-Type: application/json" \
  -X POST "$BASE_URL/deployments/filter" \
  -d '{"deployments":{"id":{"any_":["'"$DEPLOYMENT_ID"'"]}},"limit":1}' \
  | jq -r '.[0].flow_id')

echo "$LATEST_FLOW_ID"
Option B: Tiny Python via uv (no JSON flags needed)
Copy code
DEPLOYMENT_ID=<your-deployment-uuid>

uv run python - <<'PY'
import asyncio, os
from uuid import UUID
from prefect.client.orchestration import get_client

DEPLOYMENT_ID = os.environ["DEPLOYMENT_ID"]

async def main():
    async with get_client() as c:
        d = await c.read_deployment(UUID(DEPLOYMENT_ID))
        print(d.flow_id)

asyncio.run(main())
PY
If your CLI does support it, the intended command is:
Copy code
uv run prefect deployment ls --output json | jq -r '.[] | select(.id=="<DEPLOYMENT_ID>") | .flow_id'
If you prefer a pure-CLI path without API calls or Python, you can: - Find the deployment name from
uv run prefect deployment ls
(table output), then:
Copy code
uv run prefect deployment inspect "Flow Name/Deployment Name" | grep flow_id
(This prints a dict-like output you can grep; not JSON, but works in a pinch.)
g
@Marvin When i execute prefect flow-run ls this appears: Flow Runs ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┳━━━━━━━━━━━┳━━━━━━━━━━━━━━━━┓ ┃ ID ┃ Flow ┃ Name ┃ State ┃ When ┃ ┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━╇━━━━━━━━━━━╇━━━━━━━━━━━━━━━━┩ │ bcc16401-7b32-41d1-b0ef-4e5c06ac34de │ Senado Flow │ senado_flow │ COMPLETED │ 36 minutes ago │ │ 5a7e9b34-a826-428f-bdd5-1763c522563c │ Câmara Flow │ camara_flow │ FAILED │ 36 minutes ago │ │ 505359a3-ca3e-4bf5-ab31-bbe60458bb51 │ TSE Flow │ tse_flow │ COMPLETED │ 31 minutes ago │ │ 780a1376-08aa-4cbf-908e-ff7e63149b30 │ Pipeline Flow │ pipeline_flow │ COMPLETED │ 31 minutes ago │ │ 087506b2-027e-4b44-8d6e-16a99d961f92 │ Câmara Flow │ camara_flow │ RUNNING │ 37 minutes ago │ │ 63e176df-8589-4c6c-81de-6eee7f0562e2 │ Senado Flow │ senado_flow │ RUNNING │ 37 minutes ago │ │ 68cf7cfc-d62d-49d1-a438-e839b603cd48 │ TSE Flow │ tse_flow │ RUNNING │ 37 minutes ago │ │ 6a323a8f-4fc9-479e-991d-8079c8d43150 │ Pipeline Flow │ pipeline_flow │ CRASHED │ 37 minutes ago │ │ d7aca6a4-5c93-4437-931a-d100220577fb │ Senado Flow │ senado_flow │ COMPLETED │ an hour ago │ │ 14ccbd64-d585-4b5d-a4a9-f336f5be11bf │ Câmara Flow │ camara_flow │ COMPLETED │ 56 minutes ago │ │ bc9ccf13-fc39-48d2-9c66-eefa702f04d1 │ TSE Flow │ tse_flow │ COMPLETED │ an hour ago │ │ c3605124-1a28-4392-ab39-4b1427c62962 │ Pipeline Flow │ pipeline_flow │ COMPLETED │ 56 minutes ago │ │ eda42391-555c-4685-a1b1-1ade90b16279 │ TSE Flow │ tse_flow │ COMPLETED │ an hour ago │ │ 798b871a-b813-4870-b7cb-38464a687b1a │ Pipeline Flow │ pipeline_flow │ COMPLETED │ an hour ago │ │ 08d47564-683b-485a-afa9-bb8905e257ef │ Senado Flow │ senado_flow │ COMPLETED │ an hour ago │ └──────────────────────────────────────┴───────────────┴───────────────┴───────────┴────────────────┘ The thing is: Pipeline Flow, TSE Flow, Camara Flow and Senado Flow are executed in the same flow-run started by Pipeline Flow. I want to be able to grab them but i need to programatically know that these are bound i dont want to simply limit 4 and hope for the best. I need to know all the flows executed in the same flow run. How to do this
m
✅ thought for 45.8 seconds
You can reliably group “all flows executed in the same execution” by using the subflow link: every subflow run has its parent’s flow run in
parent_flow_run_id
. So the recipe is: - Get the parent flow run ID (e.g., the latest run for your deployment or flow) - Query all flow runs where
parent_flow_run_id == <parent_id>
(these are the child subflows) Since the CLI doesn’t expose a parent filter, use the REST API + jq. Below are copy/paste-able one-liners. Set your API base and auth first: - Prefect Cloud: - BASE_URL="https://api.prefect.cloud/api/accounts/&lt;ACCOUNT_ID&gt;/workspaces/&lt;WORKSPACE_ID&gt;" - export PREFECT_API_KEY="..." - Local server: - BASE_URL="http://127.0.0.1:4200/api" A) Start from deployment ID → latest parent run → children
Copy code
DEPLOYMENT_ID=<your-deployment-uuid>

# 1) Get the latest flow run for this deployment (the parent)
PARENT=$(curl -s -H "Authorization: Bearer $PREFECT_API_KEY" \
  -H "Content-Type: application/json" \
  -X POST "$BASE_URL/flow_runs/filter" \
  -d '{"deployments":{"id":{"any_":["'"$DEPLOYMENT_ID"'"]}},"sort":"START_TIME_DESC","limit":1}' \
  | jq -r '.[0].id')

echo "Parent flow run: $PARENT"

# 2) Get all direct child subflow runs for that parent
curl -s -H "Authorization: Bearer $PREFECT_API_KEY" \
  -H "Content-Type: application/json" \
  -X POST "$BASE_URL/flow_runs/filter" \
  -d '{"flow_runs":{"parent_flow_run_id":{"any_":["'"$PARENT"'"]}},"limit":2000,"sort":"START_TIME_ASC"}' \
  | jq -r '.[] | "\(.id)  \(.name)  \(.flow_id)"'
B) Start from flow name → latest run → children (if you don’t have deployment ID)
Copy code
FLOW_NAME="Pipeline Flow"

# 1) Latest run for this flow name
PARENT=$(curl -s -H "Authorization: Bearer $PREFECT_API_KEY" \
  -H "Content-Type: application/json" \
  -X POST "$BASE_URL/flow_runs/filter" \
  -d '{"flows":{"name":{"any_":["'"$FLOW_NAME"'"]}},"sort":"START_TIME_DESC","limit":1}' \
  | jq -r '.[0].id')

echo "Parent flow run: $PARENT"

# 2) Direct child subflows
curl -s -H "Authorization: Bearer $PREFECT_API_KEY" \
  -H "Content-Type: application/json" \
  -X POST "$BASE_URL/flow_runs/filter" \
  -d '{"flow_runs":{"parent_flow_run_id":{"any_":["'"$PARENT"'"]}},"limit":2000,"sort":"START_TIME_ASC"}' \
  | jq -r '.[] | "\(.id)  \(.name)  \(.flow_id)"'
Notes: - The output shows each child subflow’s run ID, its display name (e.g., “TSE Flow”, “Câmara Flow”, “Senado Flow”), and the underlying
flow_id
. - If you also need grandchildren (nested subflows), you’ll want to recurse: take those child IDs and re-run the same filter where
parent_flow_run_id
is any of the child IDs, until there are no new runs. That’s easiest to script in Python; I can share a short recursive snippet if you need it.
g
@Marvin In a flow show me how to get the name of the flow
m
✅ thought for 17.1 seconds
Here’s the simplest way in a Prefect 3.x flow to get its name at runtime:
Copy code
from prefect import flow
from prefect.runtime import flow_run

@flow
def my_flow():
    print(f"My flow name is: {flow_run.flow_name}")

if __name__ == "__main__":
    my_flow()
Notes: -
prefect.runtime.flow_run.flow_name
returns the registered name of the running flow. - You can also access other runtime fields like
flow_run.id
,
flow_run.name
(the flow run name), and
flow_run.parameters
.
g
@Marvin Is there a pythonic way to get the flow run logs from the flow run id without needing an cloud prefect account?
m
✅ thought for 54.0 seconds
Yes. You can query your local Prefect server (no Cloud needed) with the Python client and fetch logs by flow run ID. Prereqs (local server): - Start a local server if you haven’t:
uv run prefect server start
- Ensure
PREFECT_API_URL
points to it (usually `http://127.0.0.1:4200/api`) Example: fetch and print logs for a given flow run ID
Copy code
import asyncio
from prefect.client.orchestration import get_client
from prefect.client.schemas.filters import LogFilter, LogFilterFlowRunId, LogFilterLevel
from prefect.client.schemas.sorting import LogSort

FLOW_RUN_ID = "your-flow-run-uuid"

async def main():
    async with get_client() as client:
        # Fetch logs for this flow run (optionally filter WARNING+ with level=LogFilterLevel(ge_=30))
        logs = await client.read_logs(
            logs=LogFilter(flow_run_id=LogFilterFlowRunId(any_=[FLOW_RUN_ID])),
            sort=LogSort.TIMESTAMP_ASC,
            limit=1000,  # increase or paginate if needed
        )
        for l in logs:
            print(f"{l.timestamp} [{l.level}] {l.message}")

asyncio.run(main())
Paginate for large runs
Copy code
import asyncio, csv
from prefect.client.orchestration import get_client
from prefect.client.schemas.filters import LogFilter, LogFilterFlowRunId
from prefect.client.schemas.sorting import LogSort

FLOW_RUN_ID = "your-flow-run-uuid"

async def main():
    async with get_client() as client:
        limit, offset = 1000, 0
        with open("flow_logs.csv", "w", newline="") as f:
            w = csv.writer(f)
            w.writerow(["timestamp","level","flow_run_id","task_run_id","message"])
            while True:
                batch = await client.read_logs(
                    logs=LogFilter(flow_run_id=LogFilterFlowRunId(any_=[FLOW_RUN_ID])),
                    sort=LogSort.TIMESTAMP_ASC,
                    limit=limit,
                    offset=offset,
                )
                if not batch:
                    break
                for l in batch:
                    w.writerow([l.timestamp.isoformat(), l.level, l.flow_run_id, l.task_run_id, l.message])
                offset += len(batch)

asyncio.run(main())
Notes: - This works with a local Prefect server; no Cloud account is required. - If you run flows completely “standalone” (no server), logs aren’t persisted to a database, so there’s nothing to fetch by flow_run_id. In that case, write to a file via logging config or capture stdout during execution.
g
@Marvin does it need to be async? I have 4 flows and plan to get the logs only after each one of them finishes
m
✅ thought for 74.6 seconds
No, it doesn’t have to be async. Prefect provides a synchronous client you can use after each run completes: SyncPrefectClient. Here’s a minimal, synchronous example that fetches logs for one or many flow run IDs from a local server (no Cloud needed). Make sure your local server is running and PREFECT_API_URL is set (e.g., http://127.0.0.1:4200/api). Fetch logs for multiple runs in one go (sync)
Copy code
from prefect.client.orchestration import SyncPrefectClient
from prefect.client.schemas.filters import LogFilter, LogFilterFlowRunId, LogFilterLevel
from prefect.client.schemas.sorting import LogSort

flow_run_ids = [
    "run-id-1",
    "run-id-2",
    "run-id-3",
    "run-id-4",
]

with SyncPrefectClient() as client:
    limit, offset = 1000, 0
    while True:
        batch = client.read_logs(
            logs=LogFilter(
                flow_run_id=LogFilterFlowRunId(any_=flow_run_ids),
                # level=LogFilterLevel(ge_=30),  # uncomment for WARNING+
            ),
            sort=LogSort.TIMESTAMP_ASC,
            limit=limit,
            offset=offset,
        )
        if not batch:
            break
        for l in batch:
            print(f"{l.timestamp} [{l.level}] {l.flow_run_id} {l.task_run_id} {l.message}")
        offset += len(batch)
Optional: wait synchronously for a run to finish, then fetch logs
Copy code
import time
from prefect.client.orchestration import SyncPrefectClient

def wait_for_terminal_state(client, flow_run_id, poll_seconds=5):
    terminal = {"COMPLETED","FAILED","CANCELLED","CRASHED"}
    while True:
        fr = client.read_flow_run(flow_run_id)
        state_type = str(fr.state.type)
        if state_type in terminal:
            return state_type
        time.sleep(poll_seconds)

with SyncPrefectClient() as client:
    for run_id in flow_run_ids:
        final_state = wait_for_terminal_state(client, run_id)
        print(f"{run_id} finished with {final_state}")
        # then call client.read_logs(...) as above
Notes: - This works with a local Prefect server (no Cloud). If you’re running fully standalone (no server), logs aren’t persisted to query by flow_run_id—add a file handler to your logging if you need local files. - You can pass multiple flow_run_ids at once (as shown) to retrieve all four flows’ logs together.
g
@Marvin If i use the async do i get the logs as they appear?
m
✅ thought for 51.7 seconds
Short answer: Async won’t “push” logs to you automatically, but it makes it easy to poll the API and print new logs in near real-time. There’s no built-in streaming subscription; you implement a small async tailer that polls for new logs. Here’s a simple async “tail” for a single flow run that prints logs as they appear until the run finishes:
Copy code
import asyncio
from prefect.client.orchestration import get_client
from prefect.client.schemas.filters import LogFilter, LogFilterFlowRunId
from prefect.client.schemas.sorting import LogSort

async def stream_flow_run_logs(flow_run_id: str, poll_seconds: float = 2.0):
    terminal = {"COMPLETED","FAILED","CANCELLED","CRASHED"}
    offset, limit = 0, 1000
    async with get_client() as client:
        while True:
            # Fetch any new logs since the last offset
            batch = await client.read_logs(
                logs=LogFilter(flow_run_id=LogFilterFlowRunId(any_=[flow_run_id])),
                sort=LogSort.TIMESTAMP_ASC,
                limit=limit,
                offset=offset,
            )
            if batch:
                for l in batch:
                    print(f"{l.timestamp} [{l.level}] {l.message}")
                offset += len(batch)

            # Stop when the run is terminal AND no new logs in this poll
            fr = await client.read_flow_run(flow_run_id)
            state_type = str(fr.state.type) if fr.state else "PENDING"
            if state_type in terminal and not batch:
                break

            await asyncio.sleep(poll_seconds)

# Usage
# asyncio.run(stream_flow_run_logs("your-flow-run-id"))
Tailing multiple runs concurrently (e.g., your 4 flows) is just as easy with asyncio.gather:
Copy code
async def main(run_ids):
    await asyncio.gather(*(stream_flow_run_logs(rid) for rid in run_ids))

# asyncio.run(main(["id1","id2","id3","id4"]))
Notes: - Works with a local Prefect server (no Cloud needed). Ensure PREFECT_API_URL points to your server (e.g., http://127.0.0.1:4200/api). - If you run flows “standalone” without a server, there’s nothing to query by flow_run_id; use a file handler or capture stdout instead. - The CLI’s
prefect flow-run logs
has
--tail
for “last N logs” but no “follow” mode; polling via the client (as above) gives you that “as they appear” experience.
g
@Marvin async def async_save_logs(): async with get_client() as client: # Fetch logs for this flow run (optionally filter WARNING+ with level=LogFilterLevel(ge_=30)) logs = await client.read_logs( logs=LogFilter(flow_run_id=LogFilterFlowRunId(any_=["asdad46546546a"])), sort=LogSort.TIMESTAMP_ASC, limit=1000, # increase or paginate if needed ) for l in logs: print(f"{l.timestamp} [{l.level}] {l.message}") it says that logs doesnt exist in read_logs and after _any: O argumento do tipo "list[str]" não pode ser atribuído ao parâmetro "any_" do tipo "List[UUID] | None" na função "__init__" "Literal['asdad46546546a']" não pode ser atribuído a "UUID"
m
✅ thought for 48.4 seconds
You’re hitting two separate issues: 1) Wrong argument name for your Prefect version - Some versions use
log_filter=...
- Others use
logs=...
If you get “logs doesn’t exist”, switch to
log_filter=
. 2)
any_
expects UUID objects, not plain strings - Wrap your IDs with
UUID(...)
- And use a real flow run UUID (your example
"asdad46546546a"
isn’t a UUID) Here’s a fixed async example:
Copy code
import asyncio
from uuid import UUID
from prefect.client.orchestration import get_client
from prefect.client.schemas.filters import LogFilter, LogFilterFlowRunId
from prefect.client.schemas.sorting import LogSort

FLOW_RUN_ID = "0c4b12c1-2b07-4621-a845-eca0d9ae5265"  # replace with a real UUID

async def async_save_logs():
    async with get_client() as client:
        lf = LogFilter(flow_run_id=LogFilterFlowRunId(any_=[UUID(FLOW_RUN_ID)]))
        # Use the argument name your version supports:
        try:
            logs = await client.read_logs(
                log_filter=lf,  # preferred in many 3.x versions
                sort=LogSort.TIMESTAMP_ASC,
                limit=1000,
            )
        except TypeError:
            # Fallback for versions that expect `logs=` instead
            logs = await client.read_logs(
                logs=lf,
                sort=LogSort.TIMESTAMP_ASC,
                limit=1000,
            )

        for l in logs:
            print(f"{l.timestamp} [{l.level}] {l.message}")

asyncio.run(async_save_logs())
Multiple flow runs at once:
Copy code
ids = [
    "bcc16401-7b32-41d1-b0ef-4e5c06ac34de",
    "5a7e9b34-a826-428f-bdd5-1763c522563c",
]
lf = LogFilter(flow_run_id=LogFilterFlowRunId(any_=[UUID(i) for i in ids]))
Tip: If you want to confirm which keyword your version supports, check quickly in a REPL:
Copy code
import prefect; print(prefect.__version__)
g
@Marvin async def async_save_logs(flow_run_name: str, flow_run_id: str, lote_id: int): async with get_client() as client: # Fetch logs for this flow run (optionally filter WARNING+ with level=LogFilterLevel(ge_=30)) logs = await client.read_logs( log_filter=LogFilter( flow_run_id=LogFilterFlowRunId(any_=[UUID(flow_run_id)]), level=LogFilterLevel(ge_=1), ), sort=LogSort.TIMESTAMP_ASC, limit=10, # increase or paginate if needed ) print(logs) for log in logs: print(f"-->>{log.timestamp} [{log.level}] {log.message}") the logs print is returning an empty list
m
✅ thought for 64.9 seconds
You’re likely running into one of these: - The flow_run_id isn’t a real UUID (must be a valid UUID) - Your script isn’t pointed at the same API as your CLI (PREFECT_API_URL mismatch) - Version mismatch on the read_logs kwarg (log_filter vs logs) - You’re querying a parent run that has no logs (all logs live on the child subflows) Try this “self-check” snippet that verifies connectivity and fetches logs. It works with a local server (no Cloud), and will print what API you’re hitting:
Copy code
import asyncio
from uuid import UUID
import prefect
from prefect.client.orchestration import get_client
from prefect.client.schemas.filters import LogFilter, LogFilterFlowRunId  # , LogFilterLevel
from prefect.client.schemas.sorting import LogSort

FLOW_RUN_ID = "REPLACE-WITH-REAL-UUID"  # e.g. "0c4b12c1-2b07-4621-a845-eca0d9ae5265"

async def main():
    async with get_client() as client:
        # Show where the client is pointing (make sure this matches your CLI env)
        try:
            print("Prefect version:", prefect.__version__)
            print("Client base URL:", client._client.base_url)
        except Exception:
            pass

        # 1) Verify the run exists
        fr = await client.read_flow_run(UUID(FLOW_RUN_ID))
        print("Found flow run:", fr.id, "| name:", fr.name, "| flow_id:", fr.flow_id)

        # 2) Build the filter (start without level filter)
        lf = LogFilter(flow_run_id=LogFilterFlowRunId(any_=[UUID(FLOW_RUN_ID)]))
        # If you want WARNING+ later, add: level=LogFilterLevel(ge_=30)

        # 3) Call read_logs using the kwarg your version supports
        try:
            logs = await client.read_logs(
                log_filter=lf,
                sort=LogSort.TIMESTAMP_ASC,
                limit=1000,
                offset=0,
            )
        except TypeError:
            logs = await client.read_logs(
                logs=lf,
                sort=LogSort.TIMESTAMP_ASC,
                limit=1000,
                offset=0,
            )

        print(f"Fetched {len(logs)} logs")
        for l in logs:
            print(f"{l.timestamp} [{l.level}] {l.message}")

asyncio.run(main())
If this still returns 0 logs, check these items: - Make sure PREFECT_API_URL in your Python env matches the one your CLI uses: - In the same shell where you run the script: - export PREFECT_API_URL=http://127.0.0.1:4200/api - uv run prefect config view | grep PREFECT_API_URL - Confirm the same FLOW_RUN_ID has logs via CLI: - uv run prefect flow-run logs <FLOW_RUN_ID> - If the CLI prints logs but the script still returns empty, you’re almost certainly hitting a different API/workspace from the script. Ensure you export PREFECT_API_URL (and, for Cloud, PREFECT_API_KEY) before running your Python code. - If this is a “parent” run and all logs are in subflows, query those child flow-run IDs instead (children have parent_flow_run_id == your parent). I can share a short snippet to resolve children if needed.
g
@Marvin the api is pointing to the right address and i can see the logs passing the uuid to the cli
m
✅ thought for 87.4 seconds
Gotcha — since the CLI shows logs for that same UUID, your API is fine. The empty list usually boils down to one of these: - Filtering too much (e.g., level filter) — remove it to test - ID formatting (whitespace/braces) — ensure exact UUID - Version kwarg mismatch — some versions use log_filter=, others use logs= - Querying the parent run when all logs are in child subflows Here’s a minimal, version‑robust async check that mirrors the CLI behavior and prints exactly what it’s querying. It also tries the raw REST call to rule out filter-object issues.
Copy code
import asyncio
from uuid import UUID
import prefect
from prefect.client.orchestration import get_client
from prefect.client.schemas.filters import LogFilter, LogFilterFlowRunId
from prefect.client.schemas.sorting import LogSort

FLOW_RUN_ID = "paste-the-exact-uuid-here"  # e.g. "0c4b12c1-2b07-4621-a845-eca0d9ae5265"

async def main():
    async with get_client() as client:
        print("Prefect version:", prefect.__version__)
        try:
            print("Client base URL:", client._client.base_url)
        except Exception:
            pass

        # Sanity: verify the run exists
        fr = await client.read_flow_run(UUID(FLOW_RUN_ID))
        print("Found flow run:", fr.id, "| name:", fr.name)

        # 1) Try with LogFilter (no level filter)
        lf = LogFilter(flow_run_id=LogFilterFlowRunId(any_=[UUID(FLOW_RUN_ID)]))
        try:
            logs = await client.read_logs(
                log_filter=lf,  # if your version supports this kwarg
                sort=LogSort.TIMESTAMP_ASC,
                limit=1000,
                offset=0,
            )
        except TypeError:
            logs = await client.read_logs(
                logs=lf,        # fallback kwarg name for other versions
                sort=LogSort.TIMESTAMP_ASC,
                limit=1000,
                offset=0,
            )
        print("Filter-object logs:", len(logs))

        # 2) Raw REST (bypasses filter object) — should match CLI
        resp = await <http://client._client.post|client._client.post>(
            "/logs/filter",
            json={
                "logs": {"flow_run_id": {"any_": [FLOW_RUN_ID]}},
                "limit": 1000,
                "offset": 0,
                "sort": "TIMESTAMP_ASC",
            },
        )
        raw = resp.json()
        print("Raw REST logs:", len(raw))
        for l in raw[:10]:  # preview
            print(f"{l['timestamp']} [{l['level']}] {l['message']}")

asyncio.run(main())
If “Filter-object logs” is 0 but “Raw REST logs” > 0: - Your LogFilter import/build is fine, but your client version likely expects a different kwarg name; keep using whichever path returns results (in many 3.x builds it’s
logs=
, in others
log_filter=
). If both are 0: - You may be querying the parent run that has no logs while the CLI command you tried was a child subflow ID. Double-check the exact UUID passed to both places. - Or, much rarer: the run’s logs are still being flushed. Re-run after a few seconds. If you actually need “the logs from the whole execution” (parent + subflows), grab child run IDs and query them together:
Copy code
from prefect.client.schemas.filters import FlowRunFilter

children = await client.read_flow_runs(
    flow_runs=FlowRunFilter(parent_flow_run_id={"any_":[UUID(FLOW_RUN_ID)]}),
    limit=2000
)
ids = [FLOW_RUN_ID] + [str(c.id) for c in children]

resp = await <http://client._client.post|client._client.post>(
    "/logs/filter",
    json={"logs":{"flow_run_id":{"any_": ids}}, "limit": 1000, "sort": "TIMESTAMP_ASC"},
)
all_logs = resp.json()
print("Parent+children logs:", len(all_logs))
Still stuck? Paste: - The exact UUID you pass to the script (masked if needed) - Output of
prefect --version
- Whether the UUID is the parent (Pipeline Flow) or one of the child flows (TSE/Câmara/Senado)
g
@Marvin versiion 3.6.9 yes the uuid is ok yes the parent is pipeline flow. But when i type prefect flow-run logs pipeline uuid i can see thats what i want
m
✅ thought for 46.6 seconds
Thanks — since the CLI shows logs for that same parent run on 3.6.9, your API is fine. Two things usually cause the empty list in code on that version: - Using the wrong kwarg name for read_logs (3.6.9 commonly expects logs=, not log_filter=) - Passing a string instead of a UUID object (must wrap with UUID(...), and watch for stray whitespace) Try this minimal async version that works on 3.6.9 (and falls back if needed):
Copy code
import asyncio
from uuid import UUID
from prefect.client.orchestration import get_client
from prefect.client.schemas.filters import LogFilter, LogFilterFlowRunId
from prefect.client.schemas.sorting import LogSort

FLOW_RUN_ID = "paste-your-parent-uuid-exactly"

async def main():
    async with get_client() as client:
        lf = LogFilter(flow_run_id=LogFilterFlowRunId(any_=[UUID(FLOW_RUN_ID.strip())]))
        # Try both kwarg names for compatibility
        try:
            logs = await client.read_logs(
                logs=lf,  # 3.6.9 typically uses this kwarg
                sort=LogSort.TIMESTAMP_ASC,
                limit=1000,
                offset=0,
            )
        except TypeError:
            logs = await client.read_logs(
                log_filter=lf,  # fallback for other 3.x builds
                sort=LogSort.TIMESTAMP_ASC,
                limit=1000,
                offset=0,
            )

        print(f"Fetched {len(logs)} logs")
        for l in logs:
            print(f"{l.timestamp} [{l.level}] {l.message}")

asyncio.run(main())
If you still get 0: - Remove any level filter (e.g., LogFilterLevel) while testing. - Double-check the UUID is the exact same one you pass to the CLI and that it’s not quoted/trimmed incorrectly. To rule out filter-object quirks entirely, call the REST endpoint directly via the client:
Copy code
import asyncio
from prefect.client.orchestration import get_client

FLOW_RUN_ID = "paste-your-parent-uuid-exactly"

async def raw():
    async with get_client() as client:
        r = await <http://client._client.post|client._client.post>(
            "/logs/filter",
            json={
                "logs": {"flow_run_id": {"any_": [FLOW_RUN_ID]}},
                "limit": 1000,
                "offset": 0,
                "sort": "TIMESTAMP_ASC",
            },
        )
        data = r.json()
        print("Raw REST logs:", len(data))
        for l in data[:10]:
            print(f"{l['timestamp']} [{l['level']}] {l['message']}")

asyncio.run(raw())
- If Raw REST logs > 0 but read_logs == 0, it’s just the kwarg name/version mismatch — stick with the variant that returns data or consider upgrading to the latest 3.x. - If both return 0 while the CLI returns logs, please paste the exact UUID you’re using (masked is fine) and I’ll help pinpoint the mismatch.
g
@Marvin Removing all filters the logs appears and the UUID seems correct on the last log here: 33.782 | INFO | Flow run 'pipeline' - View at http://127.0.0.1:4200/runs/flow-run/e77f3be1-d8ce-4432-a257-5d8806eb4480 203033.782 | INFO | Flow run 'pipeline' - Iniciando Pipeline ETL. 203033.802 | INFO | Flow run 'pipeline' - Lote 70 iniciou. 203033.802 | WARNING | Flow run 'pipeline' - TEste 203033.803 | INFO | Flow run 'pipeline' - e77f3be1-d8ce-4432-a257-5d8806eb4480
m
✅ thought for 38.1 seconds
Great — that confirms the flow_run_id and API are fine. The empty result you saw was due to the level filter. Use the numeric Python logging levels and the 3.6.9 kwarg name. - Levels: DEBUG=10, INFO=20, WARNING=30, ERROR=40, CRITICAL=50 - On 3.6.9, prefer passing filters with logs= (not log_filter=) Example: get WARNING and above for a parent run
Copy code
import asyncio
from uuid import UUID
from prefect.client.orchestration import get_client
from prefect.client.schemas.filters import LogFilter, LogFilterFlowRunId, LogFilterLevel
from prefect.client.schemas.sorting import LogSort

FLOW_RUN_ID = "e77f3be1-d8ce-4432-a257-5d8806eb4480"  # your parent run

async def main():
    async with get_client() as client:
        lf = LogFilter(
            flow_run_id=LogFilterFlowRunId(any_=[UUID(FLOW_RUN_ID)]),
            level=LogFilterLevel(ge_=30),  # WARNING and above
        )
        logs = await client.read_logs(
            logs=lf,                     # 3.6.9 prefers 'logs='
            sort=LogSort.TIMESTAMP_ASC,
            limit=1000,
            offset=0,
        )
        print(f"Fetched {len(logs)} logs")
        for l in logs:
            print(f"{l.timestamp} [{l.level}] {l.message}")

asyncio.run(main())
If you need all levels, use
ge_=10
(DEBUG+) or remove the
level
filter entirely. If this still returns 0 while no-filter returns >0, try the raw REST call to validate the filter:
Copy code
async with get_client() as client:
    r = await <http://client._client.post|client._client.post>(
        "/logs/filter",
        json={"logs":{"flow_run_id":{"any_":[FLOW_RUN_ID]},"level":{"ge_:":30}},
              "limit":1000,"sort":"TIMESTAMP_ASC"},
    )
    print(len(r.json()))
Tip: You were using
limit=10
earlier—keep it higher when testing to avoid paging hiding matches.
g
@Marvin if i try this it gives me errors saying that logs is not correct: async def async_save_logs(flow_run_name: str, flow_run_id: str, lote_id: int): print(UUID(flow_run_id)) async with get_client() as client: # Fetch logs for this flow run (optionally filter WARNING+ with level=LogFilterLevel(ge_=30)) logs = await client.read_logs( logs=LogFilter( flow_run_id=LogFilterFlowRunId(any_=[UUID(flow_run_id)]), # level=LogFilterLevel(ge_=1), ), # sort=LogSort.TIMESTAMP_ASC, ) print(logs) for log in logs: print(f"-->>{log.timestamp} [{log.level}] {log.message}") but if i do this the logs appears: logs = await client.read_logs( log_filter=LogFilter( # flow_run_id=LogFilterFlowRunId(any_=[UUID(flow_run_id)]), # level=LogFilterLevel(ge_=1), ), # sort=LogSort.TIMESTAMP_ASC, )
m
✅ thought for 78.1 seconds
You’re on Prefect 3.6.9 — use the log_filter kwarg (not logs), and pass a UUID object in the flow_run_id filter. Here’s a minimal working version you can paste:
Copy code
import asyncio
from uuid import UUID
from prefect.client.orchestration import get_client
from prefect.client.schemas.filters import LogFilter, LogFilterFlowRunId  # , LogFilterLevel
from prefect.client.schemas.sorting import LogSort

FLOW_RUN_ID = "e77f3be1-d8ce-4432-a257-5d8806eb4480"  # replace with your parent UUID

async def async_save_logs(flow_run_name: str, flow_run_id: str, lote_id: int):
    async with get_client() as client:
        lf = LogFilter(
            flow_run_id=LogFilterFlowRunId(any_=[UUID(flow_run_id.strip())]),
            # If you want WARNING+ later, uncomment:
            # level=LogFilterLevel(ge_=30),
        )
        logs = await client.read_logs(
            log_filter=lf,                 # <- correct kwarg on 3.6.9
            sort=LogSort.TIMESTAMP_ASC,    # optional
            limit=1000,                    # increase/paginate if needed
            offset=0,
        )
        for log in logs:
            print(f"{log.timestamp} [{log.level}] {log.message}")

asyncio.run(async_save_logs("pipeline", FLOW_RUN_ID, 70))
Common gotchas: - Don’t use the logs= kwarg on 3.6.9; it’ll error. Use log_filter=. - any_ must be a list of UUIDs, not strings: any_=[UUID(flow_run_id)]. - If adding a level filter returns no results, remove it first to verify the filter works; then reintroduce with ge_=30 (WARNING+) or ge_=10 (DEBUG+) as needed.
g
@Marvin yes the level filters works the problem is in the flow run id. already tested strip but it didnt work
m
✅ thought for 42.9 seconds
Thanks — since level filters work, the issue is just with how the flow_run_id filter is being built. On Prefect 3.6.9: - Use the log_filter kwarg (not logs) - Pass a real UUID object (not a string) - Easiest: fetch the run first, then reuse fr.id (already a UUID) in the filter Try this minimal version:
Copy code
import asyncio
from uuid import UUID
from prefect.client.orchestration import get_client
from prefect.client.schemas.filters import LogFilter, LogFilterFlowRunId
from prefect.client.schemas.sorting import LogSort

async def async_save_logs(flow_run_name: str, flow_run_id_str: str, lote_id: int):
    async with get_client() as client:
        # 1) Validate the run and get a UUID instance from the API
        fr = await client.read_flow_run(UUID(flow_run_id_str.strip()))
        # fr.id is already a UUID — safest to reuse it below
        print("Resolved run:", fr.id, "| name:", fr.name)

        # 2) Build the filter with a list of UUIDs
        lf = LogFilter(
            flow_run_id=LogFilterFlowRunId(any_=[fr.id]),
            # level=LogFilterLevel(ge_=30),  # optional: WARNING+
        )

        # 3) Call read_logs with the correct kwarg for 3.6.9
        logs = await client.read_logs(
            log_filter=lf,
            sort=LogSort.TIMESTAMP_ASC,
            limit=1000,
            offset=0,
        )

        print(f"Fetched {len(logs)} logs")
        for log in logs:
            print(f"{log.timestamp} [{log.level}] {log.message}")

# Example:
# asyncio.run(async_save_logs("pipeline", "e77f3be1-d8ce-4432-a257-5d8806eb4480", 70))
If this still returns 0, do a quick A/B check with the raw REST call to rule out any filter-construction quirks:
Copy code
async with get_client() as client:
    r = await <http://client._client.post|client._client.post>(
        "/logs/filter",
        json={
            "logs": {"flow_run_id": {"any_": [flow_run_id_str.strip()]}},
            "limit": 1000,
            "offset": 0,
            "sort": "TIMESTAMP_ASC",
        },
    )
    data = r.json()
    print("Raw REST logs:", len(data))
Common gotchas to double-check: - The value passed into your function isn’t the flow run name by mistake (e.g., "pipeline") — it must be the UUID - No hidden characters in the ID (print repr(flow_run_id_str) and len(...) to confirm 36 chars) - Import the right classes: -
from prefect.client.schemas.filters import LogFilter, LogFilterFlowRunId
- Keep using
log_filter=
on 3.6.9;
logs=
will error on that version If you paste the exact snippet you’re running (with the UUID masked), I can spot-check further.
g
@Marvin How im getting the flow run id: save_logs( flow_run_name=FlowsNames.PIPELINE.value, flow_run_id=flow_run.id, lote_id=lote_id, )
m
✅ thought for 40.3 seconds
Got it — since you’re already passing
flow_run.id
(a UUID), the simplest fix is: - Accept a UUID in your function signature - Pass it directly to
LogFilterFlowRunId.any_
(do NOT wrap with
UUID(...)
) - Use
log_filter=
on Prefect 3.6.9 Example (async):
Copy code
import asyncio
from uuid import UUID
from prefect.client.orchestration import get_client
from prefect.client.schemas.filters import LogFilter, LogFilterFlowRunId, LogFilterLevel
from prefect.client.schemas.sorting import LogSort

async def async_save_logs(flow_run_name: str, flow_run_id: UUID, lote_id: int):
    async with get_client() as client:
        # Optional: narrow by level
        lf = LogFilter(
            flow_run_id=LogFilterFlowRunId(any_=[flow_run_id]),
            # level=LogFilterLevel(ge_=30),  # WARNING+
        )
        logs = await client.read_logs(
            log_filter=lf,                # <- correct kwarg on 3.6.9
            sort=LogSort.TIMESTAMP_ASC,   # optional
            limit=1000,
            offset=0,
        )
        for log in logs:
            print(f"{log.timestamp} [{log.level}] {log.message}")
How you call it (what you’re already doing):
Copy code
save_logs(
    flow_run_name=FlowsNames.PIPELINE.value,
    flow_run_id=flow_run.id,  # this is already a UUID
    lote_id=lote_id,
)
Quick sanity checks if it still returns 0: - Print the type to confirm it’s a UUID:
print(type(flow_run_id), flow_run_id)
- Remove the level filter temporarily - As a fallback, raw REST (accepts string UUIDs):
Copy code
async with get_client() as client:
    r = await <http://client._client.post|client._client.post>(
        "/logs/filter",
        json={"logs":{"flow_run_id":{"any_":[str(flow_run_id)]}},
              "limit":1000,"sort":"TIMESTAMP_ASC"},
    )
    print("Raw REST logs:", len(r.json()))
But in most cases, switching the parameter type to UUID and passing it straight into
any_
with
log_filter=
resolves the issue.
g
@Marvin async def async_save_logs(flow_run_name: str, flow_run_id: UUID, lote_id: int): print(type(flow_run_id), flow_run_id) IT RETURNS 204529.793 | INFO | Flow run 'pipeline' - <class 'str'> dcf6c6a2-fbc4-4618-8863-c1b1f07c8b60
m
✅ thought for 24.8 seconds
That print shows your arg is a str. In Prefect 3.x, values like prefect.runtime.flow_run.id are strings; LogFilterFlowRunId.any_ requires UUIDs. Fix: coerce to UUID inside your function (and use log_filter= on 3.6.9)
Copy code
import asyncio
from uuid import UUID
from prefect.client.orchestration import get_client
from prefect.client.schemas.filters import LogFilter, LogFilterFlowRunId, LogFilterLevel
from prefect.client.schemas.sorting import LogSort

async def async_save_logs(flow_run_name: str, flow_run_id, lote_id: int):
    # Coerce to UUID if a string was passed
    if isinstance(flow_run_id, str):
        flow_run_id = UUID(flow_run_id.strip())
    print(type(flow_run_id), flow_run_id)  # should be <class 'uuid.UUID'>

    async with get_client() as client:
        lf = LogFilter(
            flow_run_id=LogFilterFlowRunId(any_=[flow_run_id]),
            # level=LogFilterLevel(ge_=30),  # optional: WARNING+
        )
        logs = await client.read_logs(
            log_filter=lf,               # correct kwarg for 3.6.9
            sort=LogSort.TIMESTAMP_ASC,  # optional
            limit=1000,
            offset=0,
        )
        for log in logs:
            print(f"{log.timestamp} [{log.level}] {log.message}")
If you’re calling from within a flow and using runtime: - Call with
flow_run_id=UUID(prefect.runtime.flow_run.id)
or rely on the coercion above. This will resolve the type mismatch and let the level filters work as expected.
g
@Marvin async def async_save_logs(flow_run_name: str, flow_run_id: UUID, lote_id: int): flow_run_id = UUID(flow_run_id.strip()) print(type(flow_run_id), flow_run_id) async with get_client() as client: # Fetch logs for this flow run (optionally filter WARNING+ with level=LogFilterLevel(ge_=30)) logs = await client.read_logs( log_filter=LogFilter( flow_run_id=LogFilterFlowRunId(any_=[flow_run_id]), level=LogFilterLevel(ge_=30), 204842.755 | INFO | Flow run 'pipeline' - <class 'uuid.UUID'> 081ab36c-2a35-4067-9213-e466e983d687 204842.762 | INFO | Flow run 'pipeline' - [] now uuid but no logs yet. already tried to remove the filter level logs and put it on 10 but nothing works
m
✅ thought for 42.9 seconds
Thanks — since you now pass a UUID and still see [], let’s hard‑verify what the client is querying and rule out timing/flush issues. On 3.6.9 the correct kwarg is log_filter. Try this drop‑in that: - Confirms you’re talking to the same API as the CLI - Verifies the run exists - Calls the raw REST endpoint (same as CLI) and then read_logs with LogFilter - Retries briefly in case logs haven’t flushed yet
Copy code
import asyncio, os, time
from uuid import UUID
import prefect
from prefect.client.orchestration import get_client
from prefect.client.schemas.filters import LogFilter, LogFilterFlowRunId
from prefect.client.schemas.sorting import LogSort

async def debug_fetch_logs(flow_run_id):
    async with get_client() as client:
        print("Prefect version:", prefect.__version__)
        try:
            print("Client base URL:", client._client.base_url)
        except Exception:
            pass
        print("PREFECT_API_URL env:", os.getenv("PREFECT_API_URL"))

        # 1) Verify the run exists
        fr = await client.read_flow_run(flow_run_id)
        print("Run exists:", fr.id, "| name:", fr.name)

        # 2) Try RAW REST (should match CLI)
        for attempt in range(5):
            r = await <http://client._client.post|client._client.post>(
                "/logs/filter",
                json={
                    "logs": {"flow_run_id": {"any_": [str(flow_run_id)]}},
                    "limit": 1000,
                    "offset": 0,
                    "sort": "TIMESTAMP_ASC",
                },
            )
            raw = r.json()
            print(f"Raw REST logs (attempt {attempt}):", len(raw))
            if raw:
                break
            await asyncio.sleep(1.0)

        # 3) Try read_logs with LogFilter
        lf = LogFilter(flow_run_id=LogFilterFlowRunId(any_=[flow_run_id]))
        logs = await client.read_logs(
            log_filter=lf,
            sort=LogSort.TIMESTAMP_ASC,
            limit=1000,
            offset=0,
        )
        print("read_logs(LogFilter) count:", len(logs))
        for l in logs[:10]:
            print(f"{l.timestamp} [{l.level}] {l.message}")

# Call with your UUID (don’t wrap again; it’s already a UUID)
# await debug_fetch_logs(UUID("081ab36c-2a35-4067-9213-e466e983d687"))
What this will tell us: - If “Raw REST logs” > 0 but “read_logs(LogFilter)” is 0, keep using the REST call (or upgrade Prefect; some 3.6.x builds had kwarg/serialization quirks). - If both are 0 while
prefect flow-run logs <id>
shows logs, we’re almost certainly hitting a different API/URL from inside your flow code. The prints above (client.base_url, PREFECT_API_URL) will expose the mismatch. - If logs are 0 on the first try then appear on the next attempt, it was just a short commit/flush delay; keep the small retry. If you confirm Raw REST returns > 0, you can use it directly in your function:
Copy code
async def async_save_logs(flow_run_name: str, flow_run_id: UUID, lote_id: int):
    async with get_client() as client:
        r = await <http://client._client.post|client._client.post>(
            "/logs/filter",
            json={"logs":{"flow_run_id":{"any_":[str(flow_run_id)]}},
                  "limit":1000,"offset":0,"sort":"TIMESTAMP_ASC"},
        )
        for l in r.json():
            print(f"{l['timestamp']} [{l['level']}] {l['message']}")
Two last checks: - Ensure you really pass the parent’s UUID (not the run name). Print
repr(flow_run_id)
and
type(flow_run_id)
before querying. - If this code runs inside the same parent flow, a short
await asyncio.sleep(0.5)
before reading can help if you query immediately after emitting logs.
g
@Marvin sleeping works. now i can see the logs. Why?
m
✅ thought for 30.8 seconds
Because logs are sent to the API asynchronously and batched, there’s a small delay between when your code logs a message and when it’s persisted and queryable. When you query immediately (especially from inside or right after the run), you can outrun the background flush and the DB commit; a short sleep gives the logging worker time to push the batch and the server to persist it. How to make this reliable without guessing a sleep: - Poll until “quiet” after terminal state (i.e., the run is finished and there are no new logs for a short period). Example async “tail-until-settled” pattern:
Copy code
import asyncio
from uuid import UUID
from prefect.client.orchestration import get_client
from prefect.client.schemas.filters import LogFilter, LogFilterFlowRunId
from prefect.client.schemas.sorting import LogSort

async def fetch_logs_when_ready(flow_run_id: UUID, poll=0.5, quiet_required=2):
    async with get_client() as client:
        terminal = {"COMPLETED","FAILED","CANCELLED","CRASHED"}
        offset = 0
        quiet = 0
        while True:
            fr = await client.read_flow_run(flow_run_id)
            lf = LogFilter(flow_run_id=LogFilterFlowRunId(any_=[flow_run_id]))
            batch = await client.read_logs(
                log_filter=lf,
                sort=LogSort.TIMESTAMP_ASC,
                limit=1000,
                offset=offset,
            )
            if batch:
                for l in batch:
                    print(f"{l.timestamp} [{l.level}] {l.message}")
                offset += len(batch)
                quiet = 0
            else:
                quiet += 1

            state = str(fr.state.type) if fr.state else "PENDING"
            if state in terminal and quiet >= quiet_required:
                break

            await asyncio.sleep(poll)
Notes: - This avoids a fixed sleep and instead waits until the run is terminal and no new logs arrive for 1–2 polls. - The CLI “works” because you typically run it a bit later, after logs have already flushed. If you still see occasional gaps, bump the poll interval slightly (e.g., 0.5–1.0s) or the quiet_required count.
g
@Marvin What if i do it sync, do i still need to wait?
m
✅ thought for 34.5 seconds
Yes—you may still need to wait a moment even with the sync client. Log delivery to the API is asynchronous and batched, so there can be a brief delay between emitting a log and it being persisted and queryable. This is independent of whether your client code is sync or async. Best practice (sync): wait for terminal state, then poll logs until they “go quiet”
Copy code
import time
from uuid import UUID
from prefect.client.orchestration import SyncPrefectClient
from prefect.client.schemas.filters import LogFilter, LogFilterFlowRunId  # , LogFilterLevel
from prefect.client.schemas.sorting import LogSort

TERMINAL = {"COMPLETED","FAILED","CANCELLED","CRASHED"}

def wait_for_terminal(client: SyncPrefectClient, flow_run_id: UUID, poll=2.0):
    while True:
        fr = client.read_flow_run(flow_run_id)
        state = str(fr.state.type) if fr.state else "PENDING"
        if state in TERMINAL:
            return state
        time.sleep(poll)

def fetch_logs_until_quiet(client: SyncPrefectClient, flow_run_id: UUID, poll=0.5, quiet_required=2):
    lf = LogFilter(flow_run_id=LogFilterFlowRunId(any_=[flow_run_id]))
    offset, limit, quiet = 0, 1000, 0
    while True:
        batch = client.read_logs(
            log_filter=lf,
            sort=LogSort.TIMESTAMP_ASC,
            limit=limit,
            offset=offset,
        )
        if batch:
            for l in batch:
                print(f"{l.timestamp} [{l.level}] {l.message}")
            offset += len(batch)
            quiet = 0
        else:
            quiet += 1

        fr = client.read_flow_run(flow_run_id)
        state = str(fr.state.type) if fr.state else "PENDING"
        if state in TERMINAL and quiet >= quiet_required:
            break
        time.sleep(poll)

def save_logs_sync(flow_run_id: UUID):
    with SyncPrefectClient() as client:
        # Option A: small fixed delay (simple but less robust)
        # time.sleep(1.0)

        # Option B: robust approach
        wait_for_terminal(client, flow_run_id)
        fetch_logs_until_quiet(client, flow_run_id)

# save_logs_sync(UUID("e77f3be1-d8ce-4432-a257-5d8806eb4480"))
Why this is needed: - Prefect’s log handler sends logs to the API asynchronously; workers and the server batch/flush them. - If you query immediately after a run (or from inside it), you can outrun that flush. A short delay or a poll-until-quiet loop makes it reliable.
g
@Marvin And via CLI, do i need to wait?
m
✅ thought for 24.9 seconds
Short answer: sometimes, yes. The CLI uses the same API, so if you call it immediately after a run finishes, logs may still be flushing. Usually by the time you type the command, they’ve arrived; in automation, add a short wait or poll. Two CLI-only patterns: - Simple wait for terminal state, brief settle, then fetch logs
Copy code
ID=<your-flow-run-id>

# Wait until the run reaches a terminal state
while true; do
  STATE=$(uv run prefect flow-run inspect "$ID" -o json | jq -r '.state.type')
  case "$STATE" in
    COMPLETED|FAILED|CANCELLED|CRASHED) break;;
  esac
  sleep 2
done

# Small settle to allow log flush, then fetch
sleep 1
uv run prefect flow-run logs "$ID" > flow.log
- Poll-until-quiet (avoid fixed sleeps)
Copy code
ID=<your-flow-run-id>
LAST_SUM=""
while true; do
  OUT=$(uv run prefect flow-run logs "$ID")
  SUM=$(printf "%s" "$OUT" | shasum | awk '{print $1}')
  if [ "$SUM" = "$LAST_SUM" ]; then
    printf "%s\n" "$OUT" > flow.log
    break
  fi
  LAST_SUM="$SUM"
  sleep 1
done
Notes: -
prefect flow-run logs
has no “follow” mode;
--tail
gets the last N lines but doesn’t stream. - If you’re kicking off the run via CLI, you can also
prefect deployment run ... --watch
to wait for completion, then run the logs command with a short settle as above.
g
@Marvin So theres no state that i can check if the logs have all been successfully available? I mean, get the state from the logs?
m
✅ thought for 33.9 seconds
Short answer: No. There’s no “logs-ready” or “all logs flushed” state in Prefect. Flow/task state is tracked separately from log persistence, and logs are sent to the API asynchronously in small batches. You can’t reliably “get the state from the logs” either—use the flow run’s state for correctness. Reliable patterns: - After the run reaches a terminal state (COMPLETED/FAILED/CANCELLED/CRASHED), poll the logs until they’re “quiet” (no new logs for a couple of short polls). This avoids guessing a fixed sleep. - If you need near real-time, implement a small tailer that polls every 0.5–1s. - If you want to infer completion from logs, you can search for engine messages like “Finished in state …”, but that’s not a contract and can change—use the state API instead. Example (async, poll-until-quiet):
Copy code
import asyncio
from uuid import UUID
from prefect.client.orchestration import get_client
from prefect.client.schemas.filters import LogFilter, LogFilterFlowRunId
from prefect.client.schemas.sorting import LogSort

TERMINAL = {"COMPLETED","FAILED","CANCELLED","CRASHED"}

async def fetch_logs_until_quiet(flow_run_id: UUID, poll=0.5, quiet_required=2):
    async with get_client() as client:
        offset, quiet = 0, 0
        while True:
            fr = await client.read_flow_run(flow_run_id)
            lf = LogFilter(flow_run_id=LogFilterFlowRunId(any_=[flow_run_id]))
            batch = await client.read_logs(log_filter=lf, sort=LogSort.TIMESTAMP_ASC, limit=1000, offset=offset)
            if batch:
                for l in batch:
                    print(f"{l.timestamp} [{l.level}] {l.message}")
                offset += len(batch)
                quiet = 0
            else:
                quiet += 1
            if str(fr.state.type) in TERMINAL and quiet >= quiet_required:
                break
            await asyncio.sleep(poll)
If you prefer sync, the same “terminal + quiet” loop applies; the need to wait isn’t about sync vs async—it's about the async logging pipeline.
g
@Marvin logs = await client.read_logs( log_filter=LogFilter( flow_run_id=LogFilterFlowRunId(any_=[flow_run_id]), # level=LogFilterLevel(ge_=10), ), sort=LogSort.TIMESTAMP_ASC, ) after getting the logs without the filters, can i still filter it?
m
✅ thought for 24.2 seconds
Yes—once you have the logs in Python (as a list of Log objects), you can filter them however you want. Just note it’s more efficient to filter on the server, but client-side filtering works fine if you’ve fetched all pages. Examples (post-filtering locally):
Copy code
# Assuming `logs` is a list of Log objects you already fetched

# WARNING and above
warn_plus = [l for l in logs if l.level >= 30]

# Only task logs (exclude flow-level logs)
task_only = [l for l in logs if l.task_run_id is not None]

# Only flow-level logs (exclude task logs)
flow_only = [l for l in logs if l.task_run_id is None]

# Text contains "error" (case-insensitive)
err_text = [l for l in logs if "error" in l.message.lower()]

# Time window (inclusive start, exclusive end)
from datetime import datetime, timezone, timedelta
start = datetime.now(timezone.utc) - timedelta(hours=1)
end = datetime.now(timezone.utc)
last_hour = [l for l in logs if start <= l.timestamp < end]

# Combine: WARNING+ task logs in last hour
combo = [l for l in logs if l.task_run_id and l.level >= 30 and start <= l.timestamp < end]
Tips: - Make sure you actually pulled all logs before post-filtering. If you used a
limit
, paginate until empty:
Copy code
all_logs, offset, limit = [], 0, 1000
while True:
    batch = await client.read_logs(log_filter=lf, sort=LogSort.TIMESTAMP_ASC, limit=limit, offset=offset)
    if not batch:
        break
    all_logs.extend(batch)
    offset += len(batch)
- Level values are standard logging levels: DEBUG=10, INFO=20, WARNING=30, ERROR=40, CRITICAL=50.
g
@Marvin async def async_save_logs( flow_run_name: str, flow_run_id: UUID, lote_id: int, sleep_required: int = 1 ): async with get_client() as client: num_logs_available = 0 while True: # Fetch logs for this flow run (optionally filter WARNING+ with level=LogFilterLevel(ge_=30)) all_logs = await client.read_logs( log_filter=LogFilter( flow_run_id=LogFilterFlowRunId(any_=[flow_run_id]), # level=LogFilterLevel(ge_=10), ), # sort=LogSort.TIMESTAMP_ASC, ) num_logs_now = len(all_logs) print(f"num_logs_now: {num_logs_now}") print(f"num_logs_available: {num_logs_available}") if num_logs_now == num_logs_available: await sleep(sleep_required) else: await sleep(sleep_required) break for log in all_logs: print(f"-->>{log.timestamp} [{log.level}] {log.message}") What do you think about this way of waiting the flush of logs?
m
✅ thought for 27.2 seconds
You’re close, but that loop will break too early and can spin inefficiently: What’s off in your version - You never update num_logs_available inside the loop, so you’ll break as soon as there’s any log at all (even if more are still flushing). - You re-fetch the entire log set each poll (O(n²) as logs grow). - No terminal-state check or timeout, so you can stop too soon or loop forever. A more robust “wait until settled” pattern - Read only new logs (via offset), print them, and stop after the run is terminal and there have been no new logs for a couple polls.
Copy code
import asyncio
from uuid import UUID
from prefect.client.orchestration import get_client
from prefect.client.schemas.filters import LogFilter, LogFilterFlowRunId
from prefect.client.schemas.sorting import LogSort

TERMINAL = {"COMPLETED","FAILED","CANCELLED","CRASHED"}

async def save_logs_until_quiet(flow_run_id: UUID, poll=0.5, quiet_required=2, page_size=500, max_wait_seconds=300):
    async with get_client() as client:
        lf = LogFilter(flow_run_id=LogFilterFlowRunId(any_=[flow_run_id]))
        offset = 0
        quiet = 0
        waited = 0.0

        while True:
            # fetch only new logs
            batch = await client.read_logs(
                log_filter=lf,
                sort=LogSort.TIMESTAMP_ASC,
                limit=page_size,
                offset=offset,
            )
            if batch:
                for l in batch:
                    print(f"{l.timestamp} [{l.level}] {l.message}")
                offset += len(batch)
                quiet = 0
            else:
                quiet += 1

            # stop when run is terminal AND logs have been quiet for a couple polls
            fr = await client.read_flow_run(flow_run_id)
            state = str(fr.state.type) if fr.state else "PENDING"
            if state in TERMINAL and quiet >= quiet_required:
                break

            if waited >= max_wait_seconds:
                # safety escape
                break

            await asyncio.sleep(poll)
            waited += poll
If you really want to stick with your count-comparison approach: - Update num_logs_available = num_logs_now each iteration. - Add a terminal-state check and a small “quiet” threshold (e.g., 2 consecutive polls with no growth). - Add a timeout safeguard. Example (your style, fixed minimally):
Copy code
num_logs_available = 0
quiet = 0
while True:
    all_logs = await client.read_logs(
        log_filter=LogFilter(flow_run_id=LogFilterFlowRunId(any_=[flow_run_id])),
        sort=LogSort.TIMESTAMP_ASC,
    )
    num_logs_now = len(all_logs)
    if num_logs_now == num_logs_available:
        quiet += 1
    else:
        quiet = 0
    num_logs_available = num_logs_now

    fr = await client.read_flow_run(flow_run_id)
    if str(fr.state.type) in TERMINAL and quiet >= 2:
        break
    await asyncio.sleep(0.5)
This will wait for the flush without guessing a fixed sleep and won’t stop prematurely.
g
if str(flow_run.state.type) in TERMINAL and quiet >= quiet_required: break It gives me an error on ide: if str(flow_run.state.type) in TERMINAL and quiet >= quiet_required: break
@Marvin if str(flow_run.state.type) in TERMINAL and quiet >= quiet_required: break it gives me an error on .type: "type" nĂŁo ĂŠ um atributo conhecido de "None" (basedpyright reportOptionalMemberAccess)
m
✅ thought for 21.8 seconds
That error happens because
flow_run.state
can be None (e.g., early in the lifecycle), so accessing
.type
without a guard raises. Guard it and compare against the enum (or use
.type.name
if you prefer strings). Two safe patterns: - Using enums (recommended)
Copy code
from prefect.client.schemas.states import StateType

TERMINAL = {StateType.COMPLETED, StateType.FAILED, StateType.CANCELLED, StateType.CRASHED}

# ...
fr = await client.read_flow_run(flow_run_id)
is_terminal = fr.state is not None and fr.state.type in TERMINAL
if is_terminal and quiet >= quiet_required:
    break
- Using string names
Copy code
TERMINAL = {"COMPLETED","FAILED","CANCELLED","CRASHED"}

# ...
fr = await client.read_flow_run(flow_run_id)
state_type = fr.state.type.name if (fr.state and fr.state.type) else "PENDING"
if state_type in TERMINAL and quiet >= quiet_required:
    break
Full corrected loop (enum version with offset paging):
Copy code
import asyncio
from uuid import UUID
from prefect.client.orchestration import get_client
from prefect.client.schemas.filters import LogFilter, LogFilterFlowRunId
from prefect.client.schemas.sorting import LogSort
from prefect.client.schemas.states import StateType

TERMINAL = {StateType.COMPLETED, StateType.FAILED, StateType.CANCELLED, StateType.CRASHED}

async def save_logs_until_quiet(flow_run_id: UUID, poll=0.5, quiet_required=2, page_size=500, max_wait_seconds=300):
    async with get_client() as client:
        lf = LogFilter(flow_run_id=LogFilterFlowRunId(any_=[flow_run_id]))
        offset, quiet, waited = 0, 0, 0.0

        while True:
            batch = await client.read_logs(
                log_filter=lf,
                sort=LogSort.TIMESTAMP_ASC,
                limit=page_size,
                offset=offset,
            )
            if batch:
                for l in batch:
                    print(f"{l.timestamp} [{l.level}] {l.message}")
                offset += len(batch)
                quiet = 0
            else:
                quiet += 1

            fr = await client.read_flow_run(flow_run_id)
            is_terminal = fr.state is not None and fr.state.type in TERMINAL
            if is_terminal and quiet >= quiet_required:
                break

            if waited >= max_wait_seconds:
                break

            await asyncio.sleep(poll)
            waited += poll
This avoids the Optional access error and reliably waits until the run is terminal and logs have “gone quiet.”
g
@Marvin async def async_save_logs( flow_run_name: str, flow_run_id: UUID, lote_id: int, quiet_required=5, sleep_required=1, timeout=90, ): """ Os logs da Flow Run só ficam disponíveis depois de um tempo, Ê necessårio esperar. """ async with get_client() as client: num_logs_available = 0 quiet = 0 waited = 0 while True: # Fetch logs for this flow run (optionally filter WARNING+ with level=LogFilterLevel(ge_=30)) all_logs = await client.read_logs( log_filter=LogFilter( flow_run_id=LogFilterFlowRunId(any_=[flow_run_id]), # level=LogFilterLevel(ge_=10), ), # sort=LogSort.TIMESTAMP_ASC, ) num_logs_now = len(all_logs) if num_logs_now == num_logs_available: quiet += 1 else: quiet = 0 num_logs_available = num_logs_now if quiet >= quiet_required: break if waited >= timeout: # Escape de segurança break await asyncio.sleep(sleep_required) waited += sleep_required for log in all_logs: print(f"-->>{log.timestamp} [{log.level}] {log.message}") what about this? im not using the state of the run flow its not good for me
m
✅ thought for 39.2 seconds
Your approach will work, but I’d tighten it a bit so you don’t refetch the entire log set on every poll and so you don’t break too early. Why to tweak: - Your loop re-downloads all logs each time (O(n^2) as logs grow). - You don’t print logs until the end, so you lose the “streaming” feel. - A “quiet” period can occur mid-run if batches flush slowly; using offset avoids repeated work. A more efficient “wait until quiet” loop using offset paging and explicit sort:
Copy code
from uuid import UUID
import asyncio
from prefect.client.orchestration import get_client
from prefect.client.schemas.filters import LogFilter, LogFilterFlowRunId  # , LogFilterLevel
from prefect.client.schemas.sorting import LogSort

async def async_save_logs(
    flow_run_name: str,
    flow_run_id: UUID,
    lote_id: int,
    quiet_required: int = 5,
    sleep_required: float = 1.0,
    timeout: float = 90.0,
):
    async with get_client() as client:
        lf = LogFilter(
            flow_run_id=LogFilterFlowRunId(any_=[flow_run_id]),
            # level=LogFilterLevel(ge_=30),  # optional: WARNING+
        )

        offset = 0
        quiet = 0
        waited = 0.0
        page_size = 1000
        all_logs = []

        while True:
            # Fetch only new logs since last offset, in stable time order
            batch = await client.read_logs(
                log_filter=lf,
                sort=LogSort.TIMESTAMP_ASC,
                limit=page_size,
                offset=offset,
            )

            if batch:
                all_logs.extend(batch)
                for log in batch:
                    print(f"{log.timestamp} [{log.level}] {log.message}")
                offset += len(batch)
                quiet = 0
            else:
                quiet += 1

            if quiet >= quiet_required:
                break
            if waited >= timeout:
                break

            await asyncio.sleep(sleep_required)
            waited += sleep_required

    return all_logs
Notes: - sort=LogSort.TIMESTAMP_ASC gives stable pagination; new logs should have later timestamps. - quiet_required and sleep_required together define your “settle” window (e.g., 5 × 1s = 5s). Increase if you still see late-arriving logs. - If you want to be extra safe about late out-of-order logs, you can use a timestamp cursor instead of offset (store last_ts and pass a LogFilterTimestamp(after_=last_ts)); but offset with ascending sort is usually sufficient. If you prefer to keep your original style (no offset), at least update num_logs_available each loop and add quiet_required/timeout guards (you already did). The offset approach just avoids re-downloading the entire log set each poll and scales better for long runs.
g
@Marvin Help me debug. I have some tasks where i want to cache the result. IIN the flow im calling the task this way: # EXTRACT CANDIDATOS extract_candidatos_f = None if TasksNames.EXTRACT_TSE_CANDIDATOS not in ignore_tasks: extract_candidatos_f = [ extract_candidatos.with_options(refresh_cache=refresh_cache).submit( year=year, lote_id=lote_id ) for year in elections_years ] # Retorna lista de futures, que quando resolvidos retorna lista de strings And im passing refresh_cache as False, already confirmed it.
m
✅ thought for 197.2 seconds
Good news: your call-site looks fine. The usual reason “caching doesn’t seem to work” in Prefect 3.x is task configuration, cache key choice, or result persistence. Here’s a focused checklist and a working pattern you can compare against. What to verify on the task definition - You must opt into caching on the task. Example options: - cache_policy=INPUTS (common) or a custom cache_key_fn - cache_expiration to control TTL (optional) - persist_result=True (or set PREFECT_RESULTS_PERSIST_BY_DEFAULT=true) - By default, Prefect’s DEFAULT cache policy includes RUN_ID which prevents cache hits across different flow runs. If you want reuse across runs, do NOT use DEFAULT. Use INPUTS (and optionally TASK_SOURCE) or a custom key. Example: cache by year only (ignore lote_id), share across runs
Copy code
from datetime import timedelta
from prefect import task
from prefect.cache_policies import INPUTS, TASK_SOURCE

# Option A: Use INPUTS and only include 'year' as a parameter to the task
@task(cache_policy=INPUTS + TASK_SOURCE, cache_expiration=timedelta(days=7), persist_result=True)
def extract_candidatos(year: int) -> list[str]:
    # ... heavy work ...
    return ["..."]

# If you need lote_id for logging but DON'T want it in the cache key, do not include it
# as a task parameter. Pass it some other way (e.g., log inside the flow) or:
# Option B: Use a custom cache key function to ignore lote_id even if it's a parameter
def by_year_cache_key(ctx, params):
    return f"extract_candidatos:{params['year']}"

@task(cache_key_fn=by_year_cache_key, cache_expiration=timedelta(days=7), persist_result=True)
def extract_candidatos(year: int, lote_id: int) -> list[str]:
    return ["..."]
Call-site (what you already do)
Copy code
# refresh_cache=False means: use cache if present; write on first success
extract_candidatos_f = [
    extract_candidatos.with_options(refresh_cache=False).submit(year=year, lote_id=lote_id)
    for year in elections_years
]
Common gotchas to check - Using DEFAULT policy: includes RUN_ID → no hits across different flow runs - Missing result persistence: set persist_result=True on the task or run - Or set PREFECT_RESULTS_PERSIST_BY_DEFAULT=true - If you run on multiple machines/containers, use shared result storage (e.g., S3/GCS) or you’ll miss the cache on other workers - Cache key includes fields that change every run: - If lote_id changes and is a task parameter, INPUTS will include it in the key → no hit - Use a custom cache_key_fn that only uses the fields you want (e.g., year) - Source changes invalidate cache if you include TASK_SOURCE; if you redeploy often and want to keep cache, use INPUTS only - Result must be serializable by the result serializer (defaults work for most Python types). If not, set an explicit serializer or simplify the return type - Cache is only written on success; failed runs do not write How to confirm it’s hitting the cache - Run the flow twice with the same inputs and refresh_cache=False - In the UI, the task run should enter state “Cached” on the second run - Or check programmatically:
Copy code
from prefect.client.orchestration import get_client
from uuid import UUID

async def show_task_run_state(task_run_id: str):
    async with get_client() as client:
        tr = await client.read_task_run(UUID(task_run_id))
        print(tr.state.type)  # should be "CACHED" on cache hit
If you paste your current @task decorator for extract_candidatos (and whether you want the cache keyed by just year or by year+lote_id), I can suggest the exact configuration and cache_key_fn you need.
g
@Marvin In the task im defining: task_run_name=TasksNames.EXTRACT_TSE_CANDIDATOS + "_{year}", class TasksNames: # TSE EXTRACT_TSE_CANDIDATOS = "extract_tse_candidatos" EXTRACT_TSE_PRESTACAO_CONTAS = "extract_tse_prestacao_contas" EXTRACT_TSE_REDES_SOCIAIS = "extract_tse_redes_sociais" EXTRACT_TSE_VOTACAO = "extract_tse_votacao" THe cache policy is input but the input isnt changing. in the prefect.toml: [results] persist_by_default = true # NecessĂĄrio para o cache
m
✅ thought for 128.5 seconds
Likely cause: your cache key is changing because
lote_id
is part of the task’s inputs. With
cache_policy=INPUTS
, every parameter to the task is included in the cache key. If
lote_id
changes each run, you’ll never hit the cache even if
year
is the same. Fix options - Simplest: don’t include
lote_id
as a task parameter if you don’t want it in the cache key.
Copy code
from datetime import timedelta
from prefect import task
from prefect.cache_policies import INPUTS

@task(cache_policy=INPUTS, cache_expiration=timedelta(days=7), persist_result=True)
def extract_candidatos(year: int) -> list[str]:
    # heavy work
    ...
- Keep
lote_id
but exclude it from the cache key with a custom cache_key_fn:
Copy code
from datetime import timedelta
from prefect import task

def cache_by_year(ctx, params):
    return f"extract_candidatos:{params['year']}"

@task(cache_key_fn=cache_by_year, cache_expiration=timedelta(days=7), persist_result=True)
def extract_candidatos(year: int, lote_id: int) -> list[str]:
    # heavy work
    ...
Your call-site can stay the same:
Copy code
extract_candidatos_f = [
    extract_candidatos.with_options(refresh_cache=False).submit(year=year, lote_id=lote_id)
    for year in elections_years
]
Other checks to ensure caching works - Don’t use DEFAULT cache policy if you want reuse across runs; DEFAULT includes RUN_ID. Use INPUTS (and optionally TASK_SOURCE) or the custom function above. - Make sure results are actually being persisted: - You set [results] persist_by_default = true — verify it’s active:
Copy code
uv run prefect config view | grep PREFECT_RESULTS_PERSIST_BY_DEFAULT
- Or set per task:
persist_result=True
(as shown). - Ensure all runs that should share the cache point to the same result storage: - Local default is under ~/.prefect/storage; if workers run in different containers or users, use a shared block (e.g., S3/GCS) via
result_storage=...
. - Confirm cache hits: - Second run with same inputs should show the task state as Cached in the UI. - Or log something inside the task body; on a cache hit you won’t see it run. If you paste your current @task decorator for
extract_candidatos
(including cache settings) and confirm whether
lote_id
changes per run, I can give you the exact definition to match your intent.
g
@Marvin for log in all_logs: print( f"-->> {flow_run_name} -> {log.timestamp} - {log.flow_run_id} [{log.level}] {log.message}" ) The log comes as: 171534.510 | INFO | Task run 'run_tse_flow' - -->> tse -> 2026-02-21 201527.262761+00:00 - bcb82138-e14e-47c4-b6bb-c8beb692b447 [20] Finished in state Cached(type=COMPLETED) But i want to have the name of the task like: 171527.376 | INFO | Task run 'extract_tse_redes_sociais_PB_2018' - Finished in state Cached(type=COMPLETED)
@Marvin for log in all_logs: print( f"-->> {flow_run_name} -> {log.timestamp} - {log.flow_run_id} [{log.level}] {log.message}" ) The log comes as: 171534.510 | INFO | Task run 'run_tse_flow' - -->> tse -> 2026-02-21 201527.262761+00:00 - bcb82138-e14e-47c4-b6bb-c8beb692b447 [20] Finished in state Cached(type=COMPLETED) But i want to have the name of the task like: 171527.376 | INFO | Task run 'extract_tse_redes_sociais_PB_2018' - Finished in state Cached(type=COMPLETED)
@Marvin for log in all_logs: print( f"-->> {flow_run_name} -> {log.timestamp} - {log.flow_run_id} [{log.level}] {log.message}" ) The log comes as: 171534.510 | INFO | Task run 'run_tse_flow' - -->> tse -> 2026-02-21 201527.262761+00:00 - bcb82138-e14e-47c4-b6bb-c8beb692b447 [20] Finished in state Cached(type=COMPLETED) But i want to have the name of the task like: 171527.376 | INFO | Task run 'extract_tse_redes_sociais_PB_2018' - Finished in state Cached(type=COMPLETED)
@Marvin for log in all_logs: print( f"-->> {flow_run_name} -> {log.timestamp} - {log.flow_run_id} [{log.level}] {log.message}" ) The log comes as: 171534.510 | INFO | Task run 'run_tse_flow' - -->> tse -> 2026-02-21 201527.262761+00:00 - bcb82138-e14e-47c4-b6bb-c8beb692b447 [20] Finished in state Cached(type=COMPLETED) But i want to have the name of the task like: 171527.376 | INFO | Task run 'extract_tse_redes_sociais_PB_2018' - Finished in state Cached(type=COMPLETED)
m
✅ thought for 60.1 seconds
You can map each log’s
task_run_id
to its task run name, then use that when printing. Fetch logs, collect unique `task_run_id`s, bulk-load the task runs, and format. Example (async, Prefect 3.6.9):
Copy code
from uuid import UUID
import asyncio
from prefect.client.orchestration import get_client
from prefect.client.schemas.filters import (
    LogFilter, LogFilterFlowRunId,
    TaskRunFilter, TaskRunFilterId,
)
from prefect.client.schemas.sorting import LogSort

async def print_logs_with_task_names(flow_run_id: UUID, flow_run_fallback_name: str = ""):
    async with get_client() as client:
        # 1) Get all logs for this flow run (paginate if needed)
        logs = await client.read_logs(
            log_filter=LogFilter(flow_run_id=LogFilterFlowRunId(any_=[flow_run_id])),
            sort=LogSort.TIMESTAMP_ASC,
            limit=1000,
            offset=0,
        )

        # 2) Collect task_run_ids present in logs
        task_ids = {l.task_run_id for l in logs if l.task_run_id}

        # 3) Bulk fetch task runs to build an ID->name map
        task_name_by_id = {}
        if task_ids:
            try:
                trs = await client.read_task_runs(
                    task_run_filter=TaskRunFilter(id=TaskRunFilterId(any_=list(task_ids))),
                    limit=len(task_ids),
                )
            except TypeError:
                # Fallback for versions that expect 'task_runs=' instead of 'task_run_filter='
                trs = await client.read_task_runs(
                    task_runs=TaskRunFilter(id=TaskRunFilterId(any_=list(task_ids))),
                    limit=len(task_ids),
                )
            task_name_by_id = {tr.id: tr.name for tr in trs}

        # 4) Print with task names when available; otherwise use flow name/message
        for log in logs:
            task_name = task_name_by_id.get(log.task_run_id)
            if task_name:
                # Avoid duplicating "Task run '...'" if it's already in the message
                if "Task run '" in log.message:
                    print(f"{log.timestamp} | {log.message}")
                else:
                    print(f"{log.timestamp} | Task run '{task_name}' - {log.message}")
            else:
                # Flow-level log (no task_run_id)
                prefix = f"Flow '{flow_run_fallback_name}'" if flow_run_fallback_name else "Flow"
                print(f"{log.timestamp} | {prefix} - {log.message}")

# Usage:
# asyncio.run(print_logs_with_task_names(UUID("bcb82138-e14e-47c4-b6bb-c8beb692b447"), flow_run_fallback_name="tse"))
Notes: -
log.name
is the logger name (e.g., “prefect.task_runs”), not the task run name. You need to resolve
task_run_id
to get the display name. - Flow-level logs have `task_run_id=None`; use the flow’s name as a fallback for those. - If you prefer not to make an extra API call, you can just print
log.message
as-is; it already contains lines like “Task run 'extract_tse_redes_sociais_PB_2018' - …”.
✅ thought for 34.7 seconds
✅ thought for 28.5 seconds
You can print the task run’s actual name by resolving each log’s task_run_id to a TaskRun and using its name. Fetch all task runs for the flow run up front, build an id→name map, then format logs with that name. Async example (Prefect 3.6.9):
Copy code
from uuid import UUID
from prefect.client.orchestration import get_client
from prefect.client.schemas.filters import (
    LogFilter, LogFilterFlowRunId,
    TaskRunFilter, TaskRunFilterFlowRunId,
)
from prefect.client.schemas.sorting import LogSort

async def print_logs_with_task_names(flow_run_id: UUID, flow_run_name: str = ""):
    async with get_client() as client:
        # 1) Get all logs for this flow run (paginate if needed)
        logs = await client.read_logs(
            log_filter=LogFilter(flow_run_id=LogFilterFlowRunId(any_=[flow_run_id])),
            sort=LogSort.TIMESTAMP_ASC,
            limit=1000,
            offset=0,
        )

        # 2) Load all task runs for this flow run and map id -> name
        task_runs = await client.read_task_runs(
            task_run_filter=TaskRunFilter(flow_run_id=TaskRunFilterFlowRunId(any_=[flow_run_id])),
            limit=5000,
        )
        name_by_task_id = {tr.id: tr.name for tr in task_runs}

        # 3) Print with task names when available; otherwise fall back to flow name
        for log in logs:
            task_name = name_by_task_id.get(log.task_run_id)
            if task_name:
                print(f"{log.timestamp} | Task run '{task_name}' - {log.message}")
            else:
                print(f"{log.timestamp} | Flow run '{flow_run_name}' - {log.message}")
Notes: - Flow-level logs have
task_run_id=None
, so they won’t map to a task name; use the flow’s name for those. - If you’re aggregating logs from child subflows too, run the same mapping for each child subflow’s flow_run_id and use the corresponding task map when printing those logs.
You won’t get the task name directly from the Log object. Use the log’s
task_run_id
to look up the task run name, then format your line. Example (async), resolving all task names in one bulk call:
Copy code
from uuid import UUID
from prefect.client.orchestration import get_client
from prefect.client.schemas.filters import (
    LogFilter, LogFilterFlowRunId,
    TaskRunFilter, TaskRunFilterId,
)
from prefect.client.schemas.sorting import LogSort

async def print_logs_with_task_names(flow_run_id: UUID, flow_fallback_name: str = ""):
    async with get_client() as client:
        # 1) Fetch logs (paginate if needed)
        logs = await client.read_logs(
            log_filter=LogFilter(flow_run_id=LogFilterFlowRunId(any_=[flow_run_id])),
            sort=LogSort.TIMESTAMP_ASC,
            limit=1000,
            offset=0,
        )

        # 2) Collect task_run_ids and bulk fetch task runs to build ID->name map
        task_ids = {l.task_run_id for l in logs if l.task_run_id}
        task_name_by_id = {}
        if task_ids:
            try:
                trs = await client.read_task_runs(
                    task_run_filter=TaskRunFilter(id=TaskRunFilterId(any_=list(task_ids))),
                    limit=len(task_ids),
                )
            except TypeError:
                # Fallback for versions that expect 'task_runs=' kwarg
                trs = await client.read_task_runs(
                    task_runs=TaskRunFilter(id=TaskRunFilterId(any_=list(task_ids))),
                    limit=len(task_ids),
                )
            task_name_by_id = {tr.id: tr.name for tr in trs}

        # 3) Print with task names when available; otherwise use flow name
        for log in logs:
            if log.task_run_id:
                tname = task_name_by_id.get(log.task_run_id, "<unknown task>")
                print(f"{log.timestamp} | Task run '{tname}' - {log.message}")
            else:
                prefix = f"Flow '{flow_fallback_name}'" if flow_fallback_name else "Flow"
                print(f"{log.timestamp} | {prefix} - {log.message}")
If you prefer not to make an extra API call, you can try to parse the task name from messages that already include it:
Copy code
import re
m = re.search(r"Task run '([^']+)' - ", log.message)
task_name = m.group(1) if m else None
…but this won’t work for all log lines. Resolving via
task_run_id
is the reliable approach.
✅ thought for 43.3 seconds
What you’re seeing is the parent task that triggers the subflow (e.g., Task run 'run_tse_flow'). The inner tasks you want (e.g., 'extract_tse_redes_sociais_PB_2018') live in the child subflow’s flow run, so they won’t appear in the parent’s logs. You need to include the child subflow flow runs’ logs and then, if desired, resolve each log’s task_run_id to the task run name. Here’s an async snippet that: - Finds all child subflows of a parent flow run - Fetches logs for parent + children - Resolves task_run_id -> task name and prints “Task run 'name' - message”
Copy code
from uuid import UUID
import asyncio
from prefect.client.orchestration import get_client
from prefect.client.schemas.filters import (
    LogFilter, LogFilterFlowRunId,
    FlowRunFilter, FlowRunFilterParentFlowRunId,
    TaskRunFilter, TaskRunFilterFlowRunId,
)
from prefect.client.schemas.sorting import LogSort

async def print_parent_and_children_logs(parent_flow_run_id: UUID, parent_name: str = ""):
    async with get_client() as client:
        # 1) Find direct child subflow runs
        children = await client.read_flow_runs(
            flow_run_filter=FlowRunFilter(parent_flow_run_id=FlowRunFilterParentFlowRunId(any_=[parent_flow_run_id])),
            limit=2000,
        )
        child_ids = [c.id for c in children]

        # 2) Build task_run_id -> task_name map for parent + children
        tr_filter = TaskRunFilter(
            flow_run_id=TaskRunFilterFlowRunId(any_=[parent_flow_run_id] + child_ids)
        )
        task_runs = await client.read_task_runs(task_run_filter=tr_filter, limit=10000)
        task_name_by_id = {tr.id: tr.name for tr in task_runs}

        # 3) Fetch all logs for parent + children (paginate if needed)
        lf = LogFilter(flow_run_id=LogFilterFlowRunId(any_=[parent_flow_run_id] + child_ids))
        offset, page, all_logs = 0, 1000, []
        while True:
            batch = await client.read_logs(
                log_filter=lf,
                sort=LogSort.TIMESTAMP_ASC,
                limit=page,
                offset=offset,
            )
            if not batch:
                break
            all_logs.extend(batch)
            offset += len(batch)

        # 4) Print with task names when available; else treat as flow-level
        for log in all_logs:
            tname = task_name_by_id.get(log.task_run_id)
            if tname:
                print(f"{log.timestamp} | Task run '{tname}' - {log.message}")
            else:
                # Flow-level log (no task_run_id)
                prefix = f"Flow run '{parent_name}'" if parent_name else "Flow run"
                print(f"{log.timestamp} | {prefix} - {log.message}")

# Usage:
# asyncio.run(print_parent_and_children_logs(UUID("bcb82138-e14e-47c4-b6bb-c8beb692b447"), parent_name="tse"))
Notes: - Parent flow logs include only the parent’s tasks. Child subflow tasks (like 'extract_tse_redes_sociais_PB_2018') appear only in the child flow run’s logs. - If you want grandchildren too, repeat the child lookup for each child (or fetch recursively) and include those IDs in the filters. - If you don’t need custom formatting, the simplest path is just to include child flow run IDs in your log query and print log.message as-is; Prefect already formats lines like “Task run 'extract_tse_redes_sociais_PB_2018' - …” for those task logs.
g
@Marvin are you there?
m
✅ thought for 42.5 seconds
Yep, I’m here! Want me to: - adapt the snippet to print task-run names for the parent flow plus its child subflows, or - keep it just for the parent run, or - help with the caching setup? Also, do you prefer an async or sync version?
g
@Marvin What CLI command can i use to see the the environment variables that i set to Prefect? I want only the ones i have set
m
✅ thought for 97.6 seconds
Use prefect config view. It shows only non-default settings by default and annotates their source (env vs profile). - Show only values you’ve set (non-defaults) with sources
Copy code
uv run prefect config view --hide-defaults --show-sources
- Filter to just those coming from environment variables (macOS/Linux)
Copy code
uv run prefect config view --hide-defaults --show-sources | grep -i "env"
- Same filter in PowerShell (Windows)
Copy code
uv run prefect config view --hide-defaults --show-sources | Select-String -Pattern 'env'
Notes: - This lists only recognized Prefect settings (PREFECT_*). If you want to see all PREFECT_* variables in your shell regardless of whether Prefect recognizes them: - macOS/Linux:
env | grep '^PREFECT_'
- PowerShell:
Get-ChildItem Env: | Where-Object { $_.Name -like 'PREFECT_*' }
g
@Marvin gustavo@fedora:~/Code/prisma-parlamentar/pipeline$ uv run prefect config view --hide-defaults --show-sources you are connected to: http://127.0.0.1:4200 PREFECT_PROFILE='local' PREFECT_API_URL='http://127.0.0.1:4200/api' (from profile) PREFECT_DEBUG_MODE='False' (from prefect.toml) PREFECT_LOGGING_LEVEL='INFO' (from prefect.toml) PREFECT_LOGGING_LOG_PRINTS='True' (from prefect.toml) PREFECT_RESULTS_PERSIST_BY_DEFAULT='True' (from prefect.toml) PREFECT_TASKS_DISABLE_CACHING='False' (from prefect.toml) I was thinking that it would show the command that i typed to when i start my project and in the terminal it appears: gustavo@fedora:~/Code/prisma-parlamentar/pipeline$ uv run src/main.py Your flow 'Pipeline Flow' is being served and polling for scheduled runs! To trigger a run for this flow, use the following command: $ prefect deployment run 'Pipeline Flow/prisma-do-congresso-pipeline' You can also run your flow via the Prefect UI: http://127.0.0.1:4200/deployments/deployment/60d12090-6720-4177-8c5e-5d4b33a2e3cc and the on other terminal i can type the prefect deployment run... Which command is this?
m
✅ thought for 1142.6 seconds
That message comes from Prefect’s “serve” mode. When you run your script (uv run src/main.py) and call flow.serve(...) or serve(...), Prefect spins up a local runner that polls for scheduled/manual runs and prints: “Your flow '<name>' is being served and polling for scheduled runs! To trigger a run: prefect deployment run 'Flow/Deployment'” If you want to do the same via CLI, use:
Copy code
uv run prefect flow serve path/to/file.py:your_flow --name your-deployment
Once serving, you can trigger a run from another terminal with:
Copy code
uv run prefect deployment run 'Flow Name/your-deployment'
Notes: - There’s no top-level command named “prefect serve”; the CLI subcommand is under flow:
prefect flow serve
. - In code, you can serve a single flow with
your_flow.serve(name="your-deployment", ...)
or multiple with
from prefect import serve; serve(dep1, dep2, ...)
. - The “config view” output you shared lists recognized Prefect settings and their sources; it won’t replay the shell command you used to start the process.
g
@Marvin IN my pipeline i download about 500 html pages for 20MB or something like that. After this task completes i want to process the HTMLs. It is better to store them in memory in a list and pass it to the next task or should i store them in a zip file and only passes the string where it is?
m
✅ thought for 81.6 seconds
Short answer: both will work, but given you set PREFECT_RESULTS_PERSIST_BY_DEFAULT=true, I’d avoid returning a 20MB list from a task because Prefect will serialize and persist it. Prefer writing the HTMLs to disk (or object storage) and passing a small reference (path/manifest). Zip only if you need to move/upload a single artifact. When to choose each: - Keep in memory (list of HTML strings): simplest and fastest if everything runs on one machine/process, memory headroom is fine, and you override persistence for that task. - Persist and pass a reference (recommended here): avoids serializing 20MB into Prefect’s result store, is more resilient to retries, and scales if you later run on multiple machines/processes. - Zip: use when you need a single file to move/upload; otherwise a directory of files + manifest is simpler and faster for random access. Practical patterns A) Persist to a temp directory and pass file paths (recommended)
Copy code
from pathlib import Path
from tempfile import mkdtemp
from prefect import task, flow

@task(persist_result=True)  # small return (path), safe to persist
def download_pages(urls: list[str]) -> list[str]:
    workdir = Path(mkdtemp(prefix="html_dl_"))
    paths = []
    for i, url in enumerate(urls):
        html = fetch(url)  # your downloader
        p = workdir / f"page_{i}.html"
        p.write_text(html, encoding="utf-8")
        paths.append(str(p))
    return paths

@task
def process_pages(paths: list[str]) -> None:
    for p in paths:
        html = Path(p).read_text(encoding="utf-8")
        process(html)  # your parser

@flow
def pipeline(urls: list[str]):
    paths = download_pages(urls)
    process_pages(paths)
B) If you really want to pass the HTMLs in memory, disable result persistence just for that task to avoid writing 20MB to Prefect’s storage
Copy code
@task(persist_result=False)  # override your global setting for this task
def download_pages_in_memory(urls: list[str]) -> list[str]:
    return [fetch(u) for u in urls]
Note: this is fine if tasks run in the same process (default ConcurrentTaskRunner). If you switch to a process-based or distributed runner later, a 20MB argument will be pickled and copied—consider storage references instead. C) Zip when you need a single artifact to move/upload
Copy code
import shutil
from pathlib import Path
from tempfile import mkdtemp
from prefect import task

@task
def archive_pages(paths: list[str]) -> str:
    out = Path(mkdtemp()) / "pages.zip"
    # Create a zip from the directory containing your files
    root = Path(paths[0]).parent
    shutil.make_archive(out.with_suffix(""), "zip", root_dir=root)
    return str(out)
Extra tips - If you want to skip re-downloading across runs, persist to a deterministic location (e.g., hash of URL) and set your download task to check if the file exists before fetching. - If you keep using caching on the task, prefer returning small references and letting the cache point to persisted files rather than caching a big list payload. - If you later run workers on different machines/containers, use shared storage (S3/GCS) and pass URIs instead of local file paths.
g
@Marvin this specific task does not cache, so its better to just pass on memory, right?
m
✅ thought for 27.7 seconds
It depends on how you’re running the flow and whether you want durability: - If everything runs in a single process (default ConcurrentTaskRunner) and you don’t need durability across retries/crashes, then yes: passing the HTMLs in memory is simplest and fastest. Just make sure you disable result persistence for that task so Prefect doesn’t try to serialize ~20MB to the result store due to your global setting.
Copy code
from prefect import task

@task(persist_result=False)  # override your PREFECT_RESULTS_PERSIST_BY_DEFAULT
def download_pages(urls: list[str]) -> list[str]:
    return [fetch(u) for u in urls]
- Prefer file/URI references if any of these are true: - You might switch to a process-based or distributed runner (Dask, Ray, etc.); large in-memory payloads will be serialized and moved around. - You want robustness across retries/crashes/restarts (files survive; in-memory doesn’t). - You’ll run workers on different machines/containers in the future (use shared storage like S3/GCS and pass URIs). Zip only if you need a single artifact to move/upload; otherwise keep a directory of files and pass a small list of paths for simpler, faster random access.
g
@Marvin
Copy code
PREFECT_RESULTS_PERSIST_BY_DEFAULT
i set this thing. it will persist all of my results?
m
✅ thought for 33.2 seconds
Short answer: Yes—with PREFECT_RESULTS_PERSIST_BY_DEFAULT=true, Prefect will attempt to persist the result of every flow and task run by default, unless you explicitly override it. Key details and caveats: - Scope: Applies to both flows and tasks unless you set
persist_result=False
on a specific flow/task or via
.with_options(persist_result=False)
. - States: Results are only persisted when there is a result to persist (e.g., Completed/Cached). Failed/crashed runs don’t have your successful return value to persist. - Serializer and size: Objects must be serializable by the configured result serializer (default is Pickle). Very large objects will be serialized to disk; consider passing file/URI references instead of big in-memory payloads. - Storage location: By default, results are written to local storage under
~/.prefect/storage/
(configurable via
PREFECT_LOCAL_STORAGE_PATH
) unless you specify a result storage block (e.g., S3/GCS). - Overrides: - Per task/flow decorator:
@task(persist_result=False)
or
@flow(persist_result=False)
- Per call:
my_task.with_options(persist_result=False)(...)
- Implicit persistence: Even if the global default were False, Prefect auto-enables persistence when you configure caching (`cache_policy`/`cache_key_fn`) or set
result_storage(_key|serializer)
on the task/flow. Docs: - Results (Prefect 3.x) - Task caching (Prefect 3.x) If you have any heavy-return tasks (like your 20MB HTML batch), I recommend opting those out with
persist_result=False
or returning a small reference (e.g., path/URI) to avoid large result blobs in your store.
g
@Marvin In my case on a minority of tasks can be cached. Should i remove the persist by default env param and in the tasks set the persists=True?
m
✅ thought for 51.2 seconds
Given most of your tasks don’t need persistence/caching, I’d turn off the global default and opt-in only where needed. Recommended: - Set PREFECT_RESULTS_PERSIST_BY_DEFAULT=false (or remove it from prefect.toml) - For tasks that should cache or keep artifacts, set
persist_result=True
on the task (and add a cache policy if you want caching):
Copy code
from prefect import task
from prefect.cache_policies import INPUTS

@task(cache_policy=INPUTS, persist_result=True)
def cached_task(...):
    ...
- For heavy-return tasks that you don’t want persisted, keep
persist_result=False
(or just rely on the global false):
Copy code
@task(persist_result=False)
def big_payload_task(...):
    ...
- You can also override per-call:
Copy code
cached_task.with_options(persist_result=True)(...)
big_payload_task.with_options(persist_result=False)(...)
Why: - Caching requires persisted results; opting in on just those tasks avoids persisting large/unnecessary results elsewhere. - Persisting everything can add overhead and disk usage (especially with large payloads). To change the setting:
Copy code
uv run prefect config set PREFECT_RESULTS_PERSIST_BY_DEFAULT=false
# verify
uv run prefect config view --hide-defaults --show-sources | grep PREFECT_RESULTS_PERSIST_BY_DEFAULT
If you’ll run on multiple machines/containers and want cache to be shared, configure a result storage block (e.g., S3/GCS) on the cached tasks and persist small references elsewhere: - Results (Prefect 3.x) - Task caching (Prefect 3.x)
g
@Marvin ## LEGISLATURA extract_camara_legislatura_f = None if TasksNames.EXTRACT_CAMARA_LEGISLATURA not in ignore_tasks: extract_camara_legislatura_f = extract_legislatura( start_date=start_date, lote_id=lote_id ) load_camara_legislatura_f = load_camara_legislatura.submit( lote_id=lote_id, legislatura=extract_camara_legislatura_f ) On submit on load_camara_legislatura_f = load_camara_legislatura.submit it gives me an error: ""submit" não Ê um atributo conhecido do módulo "tasks.load.camara.load_camara_legislatura"" The task: from prefect import get_run_logger, task from config.loader import load_config from config.parameters import TasksNames from database.models.camara.camara_legislatura import CamaraLegislaturaArg from database.repository.camara.camara_legislatura import insert_camara_legislatura APP_SETTINGS = load_config() @task( task_run_name=TasksNames.LOAD_CAMARA_LEGISLATURA, retries=APP_SETTINGS.CAMARA.TASK_RETRIES, retry_delay_seconds=APP_SETTINGS.CAMARA.TASK_RETRY_DELAY, timeout_seconds=APP_SETTINGS.CAMARA.TASK_TIMEOUT, ) def load_camara_legislatura( lote_id: int, legislatura: dict | None, ) -> str: logger = get_run_logger() logger.info("Carregando Legislatura no Banco de Dados") if legislatura is None: raise ValueError( "Erro ao carregar dados de Legislatura no Banco de Dados: o parâmetro legislatura Ê Nulo" ) legislatura_data = legislatura.get("dados", []) data = CamaraLegislaturaArg( id_legislatura=legislatura_data.get("id"), data_inicio=legislatura_data.get("data_inicio"), data_fim=legislatura_data.get("data_fim"), ) insert_camara_legislatura(lote_id=lote_id, data=data) return ""
m
✅ thought for 42.0 seconds
That error means Python thinks
load_camara_legislatura
is a module, not the Prefect task function.
.submit
exists on a Prefect task, but not on a module. What likely happened - You imported the module instead of the task function, e.g.: - BAD:
import tasks.load.camara.load_camara_legislatura as load_camara_legislatura
- Then
load_camara_legislatura.submit(...)
tries to call submit on the module. Fix the import - Import the task function directly, or keep the module and access the function on it. Option A: import the task function
Copy code
from tasks.load.camara.load_camara_legislatura import load_camara_legislatura

# inside a @flow
load_fut = load_camara_legislatura.submit(lote_id=lote_id, legislatura=extract_camara_legislatura_f)
Option B: import the module, then reference the function
Copy code
import tasks.load.camara.load_camara_legislatura as camara_legislatura_mod

# inside a @flow
load_fut = camara_legislatura_mod.load_camara_legislatura.submit(
    lote_id=lote_id, legislatura=extract_camara_legislatura_f
)
Quick sanity check
Copy code
print(type(load_camara_legislatura))
# should NOT be <class 'module'>; if it is, fix the import as above
Other gotchas - Call
.submit
only inside a
@flow
function; outside a flow there’s no orchestration context. - If you don’t need concurrency, you can call the task directly (no
.submit
):
Copy code
load_result = load_camara_legislatura(lote_id=lote_id, legislatura=extract_camara_legislatura_f)
If you share the exact import line you’re using in the flow file, I can suggest the minimal change.
g
@Marvin im starting the server with uv run prefect server start. How can i check in other terminal if the server is running? Is there a command?
m
✅ thought for 261.0 seconds
Short answer: there isn’t a built-in
prefect server status
command in Prefect 3.x right now. The easiest way to check from another terminal is to hit the server’s health endpoints. Try these (default host/port are 127.0.0.1:4200 unless you changed them with
--host/--port
):
Copy code
# Basic health (API responding)
curl -sf <http://127.0.0.1:4200/api/health> && echo "healthy" || echo "down"

# Readiness (includes DB connectivity)
curl -sf <http://127.0.0.1:4200/api/ready> && echo "ready" || echo "not ready"

# Server version
curl -s <http://127.0.0.1:4200/api/admin/version>
Other quick checks: - Open the UI in a browser: http://127.0.0.1:4200 (unless you started with
--no-ui
) - Programmatic check with the Prefect client:
Copy code
python - << 'PY'
import asyncio
from prefect.client.orchestration import get_client

async def main():
    async with get_client() as client:
        err = await client.api_healthcheck()
        if err is None:
            v = await client.api_version()
            print(f"Server is up (v{v})")
        else:
            print(f"Server unavailable: {err}")

asyncio.run(main())
PY
If you started the server in the background (
prefect server start --background
), you can stop it with:
Copy code
prefect server stop
If you used a non-default host/port, replace 127.0.0.1:4200 with your values. Would you like me to tailor the commands to your exact
--host/--port
settings?
g
@Marvin uv run prefect server start --keep-alive-timeout 1 what the int means? Its 1 minute? How this flag works? What if i want to keep alive for 30 minutes? What clears the timeout? Only if theres no new runs in 30 minutes, for example?
m
✅ thought for 103.3 seconds
Great question. In Prefect 3.x,
--keep-alive-timeout
controls the idle timeout for HTTP keep‑alive connections on the API server (Uvicorn). Details: - Units: seconds. So
--keep-alive-timeout 1
= 1 second. - What it does: After the server sends a response, it keeps the TCP connection open for up to N seconds waiting for the next request. If nothing else arrives on that connection within N seconds, the server closes that connection. This maps to Uvicorn’s
timeout_keep_alive
. - Not about “runs”: It is not tied to flow runs or scheduler activity. It’s purely about HTTP connection reuse. Any new HTTP request on that same connection before the timeout will be served, then the idle timer starts again after that response. To keep connections alive for 30 minutes: - 30 minutes = 1800 seconds
Copy code
uv run prefect server start --keep-alive-timeout 1800
Or via env var:
Copy code
PREFECT_SERVER_API_KEEPALIVE_TIMEOUT=1800 uv run prefect server start
Notes: - Default is 5 seconds unless you override it. - This does not shut down the server; it only closes idle client connections. It’s commonly adjusted to be higher than your load balancer’s idle timeout to avoid premature connection closes. If you share your load balancer idle timeout, I can suggest a good value to use here.
g
@Marvin I want to close the server if theres no new connections (deployment runs) in x seconds. How can i do this?
m
✅ thought for 314.8 seconds
Short answer: there isn’t a built-in “auto-shutdown on inactivity” flag in Prefect Server. The
--keep-alive-timeout
only closes idle HTTP connections; it won’t stop the server process. Two practical ways to achieve what you want: Option A: Background server + watchdog that stops it after X seconds without new runs - Start the server in the background so it can be stopped programmatically:
Copy code
uv run prefect server start --background
- Run a small watchdog that polls for new flow runs; if none appear for your idle window, it calls `prefect server stop`:
Copy code
import asyncio
import time
import subprocess
from prefect.client.orchestration import get_client
from prefect.client.schemas.sorting import FlowRunSort

IDLE_SECONDS = 1800   # 30 minutes
POLL_SECONDS = 10

async def latest_run_id():
    async with get_client() as client:
        runs = await client.read_flow_runs(limit=1, sort=FlowRunSort.ID_DESC)
        return runs[0].id if runs else None

async def main():
    last_seen = await latest_run_id()
    last_activity = time.monotonic()
    while True:
        await asyncio.sleep(POLL_SECONDS)
        cur = await latest_run_id()
        if cur and cur != last_seen:
            last_seen = cur
            last_activity = time.monotonic()
        if time.monotonic() - last_activity >= IDLE_SECONDS:
            # stop background server
            subprocess.run(["prefect", "server", "stop"], check=False)
            print("Server stopped due to inactivity")
            return

asyncio.run(main())
Notes: - This defines “activity” as “a new flow run was created.” If you want to scope this to a specific deployment or work queue, we can add filters. - If your installed Prefect version doesn’t support
--background
, you can still use a wrapper (e.g., start the server in a separate process and kill its PID) — happy to share a variant. Option B: Use your process manager/orchestrator - If you run the server under systemd, supervisord, or a container orchestrator, you can implement idle shutdown outside Prefect by: - Running a sidecar/watchdog (like the script above) that signals the server process to exit on inactivity - Or scheduling shutdown after a fixed lifetime and only starting the server when needed If by “no new connections” you literally mean “no HTTP requests hit the API,” Prefect doesn’t expose request counters. You’d need to: - Parse access logs from a reverse proxy in front of the server, or - Instrument the server with an external monitor, then apply the same “idle for X seconds → stop” logic. Can you clarify how you want to define “inactivity”? For example: - No new flow runs created - No new runs started - No worker heartbeats - No HTTP requests to the API Once I know which signal you care about, I can tailor the watchdog to that exact condition (and to a specific deployment/work pool if needed).
g
@Marvin mine: prefect>=3.6.9 theres no prefect server status command?
m
✅ thought for 19.8 seconds
You’re correct — in Prefect 3.6.9 there is no
prefect server status
command. Use an HTTP health/readiness check instead (replace host/port if you changed them): - Unix/macOS:
Copy code
curl -sf <http://127.0.0.1:4200/api/health> && echo "healthy" || echo "down"
curl -sf <http://127.0.0.1:4200/api/ready> && echo "ready" || echo "not ready"
- Windows PowerShell:
Copy code
(Invoke-WebRequest <http://127.0.0.1:4200/api/health> -UseBasicParsing -TimeoutSec 5).StatusCode
(Invoke-WebRequest <http://127.0.0.1:4200/api/ready> -UseBasicParsing -TimeoutSec 5).StatusCode
Programmatic check with the Prefect client:
Copy code
python - << 'PY'
import asyncio
from prefect.client.orchestration import get_client

async def main():
    async with get_client() as client:
        err = await client.api_healthcheck()
        if err is None:
            v = await client.api_version()
            print(f"Server is up (v{v})")
        else:
            print(f"Server unavailable: {err}")

asyncio.run(main())
PY
Note: -
prefect server services list-services
exists, but it shows which loop services are enabled/disabled (scheduler, late-runs, etc.); it’s not a live health check of the API. - If you started the server with
--background
, you can stop it with:
Copy code
prefect server stop
g
@Marvin uv run prefect deployment run 'Pipeline Flow/prisma-do-congresso-pipeline' $params at the shell how to stop the server when the deployment run stops?
m
✅ thought for 104.0 seconds
Yes—use
--watch
on the run, and start the server in the background so you can stop it after the run finishes. Bash (Linux/macOS):
Copy code
# 1) Start the server in the background so it's managed by Prefect
uv run prefect server start --background

# 2) Ensure we always stop the server when this shell exits
trap 'uv run prefect server stop' EXIT

# 3) Kick off the deployment and wait for terminal state
uv run prefect deployment run 'Pipeline Flow/prisma-do-congresso-pipeline' --watch $params
One‑liner that always stops the server afterward and preserves the run’s exit code:
Copy code
uv run prefect server start --background && \
uv run prefect deployment run 'Pipeline Flow/prisma-do-congresso-pipeline' --watch $params; \
code=$?; uv run prefect server stop; exit $code
PowerShell (Windows):
Copy code
uv run prefect server start --background
try {
  uv run prefect deployment run 'Pipeline Flow/prisma-do-congresso-pipeline' --watch $params
} finally {
  uv run prefect server stop
}
Notes: -
--watch
makes the CLI wait until the flow run reaches a terminal state before returning. -
prefect server stop
only works for servers started with
--background
(it uses the PID file). If you started the server in the foreground, you’ll need to Ctrl+C that process instead. - Keep
$params
as your repeated
--param key=value
entries (e.g.,
-p a=1 -p b=2
).
g
@Marvin I think i might have unconfigured something, now its appearing this log: 175840.250 | WARNING | Task run 'extract_camara_legislaturas' - Timeout of 8000.0 seconds configured for this task, but the task is running in a worker thread. Timeouts in worker threads cannot interrupt blocking operations like
time.sleep()
, network requests, or file I/O. The timeout will only take effect after the blocking operation completes. Consider using an async task with
await
statements for reliable timeout behavior. See https://docs.prefect.io/v3/how-to-guides/workflows/write-and-run#task-timeout-behavior for more information. but i dont remember doing nothing related to task timeout
m
✅ thought for 251.4 seconds
That warning appears only when a task has a timeout configured and it’s executing in a worker thread. In Prefect 3.x there’s no global/default task timeout setting—timeouts are set in code—so the 8000.0 seconds must be coming from one of these: -
@task(timeout_seconds=8000)
on the task definition -
some_task.with_options(timeout_seconds=8000)
used somewhere - Creating a
Task(..., timeout_seconds=8000)
directly How to confirm and fix: 1) Find the source of 8000 - Search your repo:
Copy code
grep -R "timeout_seconds" .
grep -R ".with_options(" .
- Or quickly inspect in Python:
Copy code
print(extract_camara_legislaturas.timeout_seconds)
2) If you don’t want a timeout - Remove the argument or reset it:
Copy code
@task(timeout_seconds=None)
def extract_camara_legislaturas(...):
    ...
3) If you do want a timeout to actually interrupt blocking calls Thread-based execution cannot interrupt blocking operations (
time.sleep
, network/file I/O), so the timeout only triggers after the blocking call returns. Use one of: - Process pool for sync code:
Copy code
from prefect import flow
from prefect.task_runners import ProcessPoolTaskRunner

@flow(task_runner=ProcessPoolTaskRunner())
def my_flow():
    extract_camara_legislaturas()
- Or make the task async and use awaited I/O with per-call timeouts (e.g., httpx/asyncpg/etc.), which allows cooperative cancellation:
Copy code
from prefect import task
import httpx

@task(timeout_seconds=60)
async def extract_camara_legislaturas(...):
    async with httpx.AsyncClient(timeout=60) as client:
        r = await client.get(url)  # await points enable cancellation
        ...
- Also add native timeouts to any blocking libraries you call (e.g.,
requests.get(..., timeout=60)
). More details: Task timeout behavior If you paste the task definition for
extract_camara_legislaturas
, I can point out exactly where the timeout is coming from and suggest the cleanest adjustment.
g
@Marvin Can i define a timeout for the entire pipeline?
m
✅ thought for 101.6 seconds
Yes. In Prefect 3.x you can set a timeout for the entire flow (pipeline) with the
@flow
decorator’s
timeout_seconds
parameter. Example:
Copy code
from prefect import flow

@flow(timeout_seconds=3600)  # 1 hour for the whole pipeline
def pipeline():
    ...
Behavior and caveats: - When the total wall time exceeds
timeout_seconds
, the flow run is marked Failed with state name “TimedOut” and a message like “Flow run exceeded timeout of N second(s)”. - Retries (if configured on the flow) will each get a fresh timeout window. - Sync flows: timeouts rely on OS-level cancellation under the hood; long, truly blocking calls in threads may only be interrupted at the next Python instruction. Async flows cancel reliably at
await
points. - Windows: flow-level timeouts are not enforced (platform limitation). On Linux/macOS they work as described. If you share your platform and whether your flow is sync or async, I can suggest the most reliable pattern (e.g., converting long blocking sections to async or isolating them in a process pool).
g
@Marvin If a task of my flow fails the whole pipeline stops and fails. Is this normal? What if i want only that task to fail and not the others?
m
✅ thought for 155.5 seconds
Short answer: Yes—by default Prefect will stop the flow when a task your code depends on fails. That’s normal. If you want other work to continue, don’t immediately consume the failed task’s result; instead, handle the task’s state explicitly. Ways to let the flow continue after a task failure: 1) Get the state instead of the value - This prevents an exception from being raised at the call site.
Copy code
from prefect import flow, task

@task
def risky():
    raise RuntimeError("boom")

@task
def downstream(x):
    return f"ok: {x}"

@flow
def pipeline():
    s = risky(return_state=True)  # returns a State, not the value
    if s.is_failed():
        # handle failure without stopping the flow
        alt = "fallback"
        y = downstream(alt)
    else:
        y = downstream(s.result())
    return y
2) Submit tasks concurrently and inspect results after - Independent tasks can run even if one fails.
Copy code
@flow
def pipeline():
    a_f = task_a.submit()
    b_f = task_b.submit()
    # Later, inspect without raising
    a_res = a_f.result(raise_on_failure=False)
    b_res = b_f.result(raise_on_failure=False)
    # a_res/b_res will be exceptions for failed tasks; skip or fallback
3) Use wait_for to enforce ordering without data dependency - Task B runs after Task A completes (even if A failed), since it doesn’t consume A’s output.
Copy code
b_f = task_b.submit(wait_for=[a_f])
4) Map with return_state and filter successes
Copy code
states = my_task.map(items, return_state=True)
results = [s.result() for s in states if s.is_completed()]
Why the flow stopped for you: - If you write
x = risky()
and it fails, Prefect raises the task exception immediately. Any downstream line that uses
x
won’t run. - If another task depends on the failed task’s result, Prefect raises UpstreamTaskError to prevent running with bad inputs. Rule of thumb: - Want failure to stop the pipeline? Call tasks normally and pass their results downstream. - Want the pipeline to continue on failures? Ask for
return_state=True
(or use
.submit()
and
.result(raise_on_failure=False)
), check the state, and branch or fallback accordingly. If you share a small snippet of the failing pattern, I can rewrite it to keep the pipeline going while handling that task’s failure cleanly.