Gustavo Fonseca
10/18/2025, 6:18 PMMarvin
10/18/2025, 6:18 PMMarvin
10/18/2025, 6:21 PMfrom 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.Gustavo Fonseca
10/18/2025, 6:36 PMMarvin
10/18/2025, 6:36 PMMarvin
10/18/2025, 6:41 PMtask_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:
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:
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):
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):
psql "$PREFECT_SERVER_DATABASE_CONNECTION_URL" -c "DELETE FROM task_run WHERE flow_run_id IS NULL;"
Then:
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:
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:
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 referenceGustavo Fonseca
10/18/2025, 6:45 PMMarvin
10/18/2025, 6:45 PMMarvin
10/18/2025, 6:48 PMuv run prefect config view --show-sources --show-secrets | grep DATABASE_CONNECTION_URL
You should see something like:
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):
rm -f ~/.prefect/prefect.db
3) Recreate/reset the DB:
uv run prefect server database reset -y
4) Start the server again:
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:
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): ReferenceGustavo Fonseca
10/18/2025, 7:16 PMMarvin
10/18/2025, 7:16 PMMarvin
10/18/2025, 7:18 PMuv 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:
# 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:
# 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):
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):
@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: DocsGustavo Fonseca
10/18/2025, 7:19 PMGustavo Fonseca
10/18/2025, 7:19 PMGustavo Fonseca
10/18/2025, 7:19 PMMarvin
10/18/2025, 7:19 PMMarvin
10/18/2025, 7:20 PMfetch_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):
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.:
rm -f ~/.prefect/prefect.db
3) Reset and start the server cleanly
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
export PREFECT_API_URL="<http://127.0.0.1:4200/api>"
5) Run your flow with logging captured
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).Gustavo Fonseca
10/18/2025, 7:33 PMMarvin
10/18/2025, 7:33 PMGustavo Fonseca
10/18/2025, 7:33 PMMarvin
10/18/2025, 7:33 PMMarvin
10/18/2025, 7:34 PMtime.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:
uv run prefect server start
- For faster local feedback, reduce the flush interval:
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:
@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 referenceGustavo Fonseca
10/18/2025, 7:37 PMGustavo Fonseca
10/18/2025, 7:37 PMMarvin
10/18/2025, 7:37 PMMarvin
10/18/2025, 7:40 PMPREFECT_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)
PREFECT_API_SERVICES_EVENT_PERSISTER_FLUSH_INTERVAL=1 uv run prefect server start
Option B â Persist to your Prefect profile (CLI)
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 referenceGustavo Fonseca
10/18/2025, 7:43 PMGustavo Fonseca
10/18/2025, 7:45 PMMarvin
10/18/2025, 7:45 PMMarvin
10/18/2025, 7:46 PMprefect 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:
uv run prefect config view --show-sources
uv run prefect profile ls
- To unset or change later:
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:
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 profilesGustavo Fonseca
10/18/2025, 7:55 PMMarvin
10/18/2025, 7:55 PMMarvin
10/18/2025, 7:55 PMuv run prefect config unset PREFECT_API_SERVICES_EVENT_PERSISTER_FLUSH_INTERVAL -y
Then restart your server to apply the change:
uv run prefect server start
Verify itâs gone (and back to the default of 5 seconds):
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:
unset PREFECT_API_SERVICES_EVENT_PERSISTER_FLUSH_INTERVAL
Docs:
- Settings and profiles
- Settings referenceGustavo Fonseca
10/18/2025, 7:58 PMGustavo Fonseca
10/18/2025, 7:58 PMMarvin
10/18/2025, 7:58 PMMarvin
10/18/2025, 8:00 PMPREFECT_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 referenceGustavo Fonseca
10/18/2025, 8:19 PM{
"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:
{
"lat": 38.9,
"lon": -77
}Gustavo Fonseca
10/18/2025, 8:20 PM{
"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:
{
"lat": 38.9,
"lon": -77
}Marvin
10/18/2025, 8:20 PMMarvin
10/18/2025, 8:23 PM{
"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:
{
"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):
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:
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 tasksGustavo Fonseca
10/18/2025, 8:38 PMMarvin
10/18/2025, 8:38 PMMarvin
10/18/2025, 8:39 PMuv run prefect block type ls
- Generate a creation link for Secret:
uv run prefect block create secret
- Click the URL, fill Name = secret-block, Value = chave, Save.
- Verify:
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:
uv run python -c "from prefect.blocks.system import Secret; Secret(value='chave').save('secret-block')"
- Verify:
uv run prefect block ls
uv run prefect block inspect secret/secret-block
Use in flows:
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 secretsGustavo Fonseca
10/18/2025, 9:49 PMGustavo Fonseca
10/18/2025, 9:49 PMMarvin
10/18/2025, 9:49 PMMarvin
10/18/2025, 9:53 PM@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:
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:
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-filesystemsGustavo Fonseca
10/18/2025, 11:08 PMGustavo Fonseca
10/18/2025, 11:15 PMMarvin
10/18/2025, 11:15 PMMarvin
10/18/2025, 11:18 PMfrom 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 triggersGustavo Fonseca
10/19/2025, 12:18 AMMarvin
10/19/2025, 12:18 AMMarvin
10/19/2025, 12:22 AMfrom 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):
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:
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:
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:
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-githubGustavo Fonseca
10/19/2025, 12:47 AMMarvin
10/19/2025, 12:47 AMMarvin
10/19/2025, 12:48 AMuv 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
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:
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:
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
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:
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 guideGustavo Fonseca
10/19/2025, 2:15 AMGustavo Fonseca
10/19/2025, 2:15 AMMarvin
10/19/2025, 2:15 AMMarvin
10/19/2025, 2:16 AMfrom 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:
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: ResultsGustavo Fonseca
10/19/2025, 2:21 AMMarvin
10/19/2025, 2:21 AMMarvin
10/19/2025, 2:21 AMfrom 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)
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.Gustavo Fonseca
10/19/2025, 5:54 PMMarvin
10/19/2025, 5:54 PMMarvin
10/19/2025, 5:55 PMfrom 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
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
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
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"Marvin
10/19/2025, 5:55 PMflows/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()
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
- Execute the flow:
uv run python flows/pipeline.py
- Or serve/schedule locally:
uv run python -c "from flows.pipeline import pipeline; pipeline.serve(name='local-pipeline')"
```Gustavo Fonseca
10/19/2025, 6:18 PMGustavo Fonseca
10/19/2025, 6:18 PMMarvin
10/19/2025, 6:19 PMMarvin
10/19/2025, 6:19 PMdownload_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
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]:Marvin
10/19/2025, 6:19 PMtasks/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.Gustavo Fonseca
10/20/2025, 12:59 AMMarvin
10/20/2025, 12:59 AMMarvin
10/20/2025, 1:01 AM[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
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)
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)
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:
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")
# laterMarvin
10/20/2025, 1:01 AMGustavo Fonseca
10/20/2025, 2:15 AMMarvin
10/20/2025, 2:15 AMMarvin
10/20/2025, 2:17 AMVariable.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
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:
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:
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.Gustavo Fonseca
10/20/2025, 2:32 AMGustavo Fonseca
10/20/2025, 2:32 AMMarvin
10/20/2025, 2:32 AMMarvin
10/20/2025, 2:32 AMfrom 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
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
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: VariablesGustavo Fonseca
10/20/2025, 2:42 AMGustavo Fonseca
10/20/2025, 2:42 AMMarvin
10/20/2025, 2:43 AMMarvin
10/20/2025, 2:43 AM[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
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
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.Gustavo Fonseca
10/20/2025, 3:21 AMGustavo Fonseca
10/20/2025, 3:21 AMMarvin
10/20/2025, 3:21 AMMarvin
10/20/2025, 3:22 AM<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
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:
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
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 tasksGustavo Fonseca
10/20/2025, 3:30 AMMarvin
10/20/2025, 3:30 AMMarvin
10/20/2025, 3:30 AM<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)
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:
@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)
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:
uv run prefect version
- Donât include Slack formatting in code blocks you paste into your editor.
Docs:
- Flows and tasks: Flows and tasksGustavo Fonseca
10/21/2025, 1:43 AMMarvin
10/21/2025, 1:43 AMMarvin
10/21/2025, 1:44 AMfrom __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
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
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.
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 fieldsMarvin
10/21/2025, 1:44 AMflows/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.Gustavo Fonseca
10/22/2025, 12:16 AMGustavo Fonseca
10/22/2025, 12:16 AMMarvin
10/22/2025, 12:16 AMMarvin
10/22/2025, 12:21 AMonly) and use native Python filtering so a new run can execute just a subset.
- Combine with task retries for transient failures.
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:
sync_all(endpoints=ALL_ENDPOINTS)
- Re-run only failing ones:
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.
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:
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.Gustavo Fonseca
10/22/2025, 12:25 AMGustavo Fonseca
10/22/2025, 12:26 AMMarvin
10/22/2025, 12:26 AMMarvin
10/22/2025, 12:30 AM@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
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
download_tse_endpoint.with_options(
task_run_name="Download {endpoint}"
).map(endpoints)
3) Use a callable for complex naming
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.Gustavo Fonseca
10/22/2025, 12:52 AMMarvin
10/22/2025, 12:52 AMMarvin
10/22/2025, 12:53 AMtask_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
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:
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)
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:
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_nameGustavo Fonseca
10/22/2025, 2:04 AMMarvin
10/22/2025, 2:04 AMMarvin
10/22/2025, 2:05 AMpipeline 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
# 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
prefect worker start -p <your-work-pool>
Trigger the deployment
# 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:
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.Gustavo Fonseca
10/22/2025, 2:07 AMMarvin
10/22/2025, 2:07 AMMarvin
10/22/2025, 2:08 AMpipeline 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)
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
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
prefect worker start -p process-pool
Step 4) Run the deployment (and watch logs)
prefect deployment run pipeline/pipeline-dev --watch
Pass parameters (e.g., your date_today flow parameter) if needed:
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 workersGustavo Fonseca
10/22/2025, 2:24 AMMarvin
10/22/2025, 2:24 AMMarvin
10/22/2025, 2:28 AMfrom 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:
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:
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:
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.Gustavo Fonseca
10/22/2025, 11:23 PMMarvin
10/22/2025, 11:23 PMMarvin
10/22/2025, 11:24 PMINPUTS constant or a CachePolicy() instance)
- Use the mapped object in your @task(cache_policy=...)
Example
TOML (e.g., config.toml)
[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
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
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
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.Gustavo Fonseca
10/22/2025, 11:36 PMMarvin
10/22/2025, 11:36 PMMarvin
10/22/2025, 11:37 PM"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
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.Gustavo Fonseca
10/22/2025, 11:56 PMMarvin
10/22/2025, 11:56 PMMarvin
10/22/2025, 11:57 PMname parameter so each run shows the key in the UI.
Add task_run_name="extract_tse_{name}" to your task decorator:
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:
# 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:
extract_tse.with_options(task_run_name="extract_tse_{name}").submit(name=key, url=url)
For complex formatting/truncation, you can use a callable:
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)Gustavo Fonseca
10/23/2025, 12:05 AMMarvin
10/23/2025, 12:05 AMMarvin
10/23/2025, 12:06 AMfrom 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:
@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:
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:
[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:
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.Gustavo Fonseca
10/23/2025, 12:33 AMMarvin
10/23/2025, 12:33 AMMarvin
10/23/2025, 12:38 AMAPP_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.
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:
@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:
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.
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:
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.Marvin
10/23/2025, 12:38 AM@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.Gustavo Fonseca
10/23/2025, 2:16 AMprocess_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?Marvin
10/23/2025, 2:16 AMMarvin
10/23/2025, 2:16 AMrunner.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:
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_limitGustavo Fonseca
10/23/2025, 2:26 AMGustavo Fonseca
10/23/2025, 2:26 AMMarvin
10/23/2025, 2:26 AMMarvin
10/23/2025, 2:31 AMrefresh_cache=True on the task (decorator or via with_options) to bypass and overwrite any existing cache for that task.
- Example:
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:
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:
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.Gustavo Fonseca
10/24/2025, 1:40 AMMarvin
10/24/2025, 1:40 AMMarvin
10/24/2025, 1:43 AMget_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):
[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:
@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 referenceGustavo Fonseca
10/24/2025, 2:06 AMMarvin
10/24/2025, 2:06 AMMarvin
10/24/2025, 2:13 AMfetch_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
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
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 APIGustavo Fonseca
10/24/2025, 2:15 AMMarvin
10/24/2025, 2:15 AMMarvin
10/24/2025, 2:19 AMfrom 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):
# ... 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 concurrentlyMarvin
10/24/2025, 2:19 AMGustavo Fonseca
10/24/2025, 2:25 AMGustavo Fonseca
10/24/2025, 2:25 AMMarvin
10/24/2025, 2:25 AMMarvin
10/24/2025, 2:27 AMfrom 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.futuresGustavo Fonseca
10/24/2025, 2:34 AMGustavo Fonseca
10/24/2025, 2:34 AMMarvin
10/24/2025, 2:34 AMMarvin
10/24/2025, 2:35 AMfrom 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:
frentes_membros_f = extract_frentes_membros.submit(frentes_f)
2) Cast at the call site (minimal change)
from typing import Any, cast
frentes_membros_f = extract_frentes_membros.submit(cast(Any, frentes_f))
3) Ignore the warning inline (PyLance/Pyright)
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:
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 conceptGustavo Fonseca
10/24/2025, 2:38 AMMarvin
10/24/2025, 2:38 AMMarvin
10/24/2025, 2:39 AMPrefectFuture 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
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â)
from typing import Any, cast
frentes_membros_f = extract_frentes_membros.submit(cast(Any, frentes_f))
3) Ignore the warning inline
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`:
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.
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 resultsMarvin
10/24/2025, 2:39 AMrun_coro_as_sync): Async utilities API
- Tasks concept: Tasks conceptGustavo Fonseca
10/24/2025, 2:48 AMGustavo Fonseca
10/24/2025, 2:48 AMMarvin
10/24/2025, 2:48 AMMarvin
10/24/2025, 2:50 AMfrom 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:
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:
from typing import Any, cast
frentes_membros_f = extract_frentes_membros.submit(cast(Any, frentes_f))
- Or ignore inline:
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):
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()
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:Marvin
10/24/2025, 2:50 AMGustavo Fonseca
10/24/2025, 3:06 AMMarvin
10/24/2025, 3:06 AMMarvin
10/24/2025, 3:08 AMfrom 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:
@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:
[logging]
level = "INFO"
log_prints = true
- Or per task: @task(log_prints=True)
- Then add print lines in the same spots:
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:Marvin
10/24/2025, 3:08 AMGustavo Fonseca
10/25/2025, 9:51 PMMarvin
10/25/2025, 9:51 PMMarvin
10/25/2025, 9:54 PMprefect.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
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.Gustavo Fonseca
10/25/2025, 10:51 PMMarvin
10/25/2025, 10:51 PMGustavo Fonseca
10/25/2025, 10:52 PMMarvin
10/25/2025, 10:52 PMMarvin
10/25/2025, 10:54 PMprogress_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
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.Gustavo Fonseca
10/25/2025, 11:00 PMMarvin
10/25/2025, 11:00 PMMarvin
10/25/2025, 11:02 PMextract_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
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:
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.Gustavo Fonseca
10/25/2025, 11:08 PMMarvin
10/25/2025, 11:08 PMMarvin
10/25/2025, 11:09 PM<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
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:
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.Gustavo Fonseca
10/25/2025, 11:24 PMMarvin
10/25/2025, 11:24 PMMarvin
10/25/2025, 11:28 PMaupdate_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
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 APIGustavo Fonseca
10/25/2025, 11:45 PMMarvin
10/25/2025, 11:45 PMMarvin
10/25/2025, 11:46 PMimport 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:
# 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.Gustavo Fonseca
10/25/2025, 11:47 PMGustavo Fonseca
10/25/2025, 11:47 PMMarvin
10/25/2025, 11:47 PMMarvin
10/25/2025, 11:48 PMGustavo Fonseca
10/26/2025, 12:22 AMfrom 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?Marvin
10/26/2025, 12:22 AMMarvin
10/26/2025, 12:24 AMfrom 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:
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.Gustavo Fonseca
10/26/2025, 12:42 AMMarvin
10/26/2025, 12:42 AMMarvin
10/26/2025, 12:42 AMfrom 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:
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 APIGustavo Fonseca
10/26/2025, 12:55 PMMarvin
10/26/2025, 12:55 PMMarvin
10/26/2025, 12:56 PMdeputados_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.
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.
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.Gustavo Fonseca
10/26/2025, 1:04 PMMarvin
10/26/2025, 1:04 PMMarvin
10/26/2025, 1:05 PMfrom 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:Marvin
10/26/2025, 1:05 PMextract_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.Gustavo Fonseca
10/26/2025, 1:17 PMMarvin
10/26/2025, 1:17 PMMarvin
10/26/2025, 1:22 PM@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:
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
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:
# 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.Gustavo Fonseca
10/26/2025, 4:51 PMMarvin
10/26/2025, 4:51 PMMarvin
10/26/2025, 4:53 PMfrom 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)
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:
@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:
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.Marvin
10/26/2025, 4:53 PMGustavo Fonseca
10/26/2025, 10:01 PMMarvin
10/26/2025, 10:01 PMMarvin
10/26/2025, 10:01 PMcache_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:
"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)
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:
@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.Gustavo Fonseca
10/26/2025, 10:08 PMMarvin
10/26/2025, 10:08 PMMarvin
10/26/2025, 10:08 PMimport 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
# 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)
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
# 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:
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?Gustavo Fonseca
10/26/2025, 10:40 PMMarvin
10/26/2025, 10:40 PMMarvin
10/26/2025, 10:41 PMimport 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.Gustavo Fonseca
10/26/2025, 10:52 PMMarvin
10/26/2025, 10:52 PMMarvin
10/26/2025, 10:53 PMfrom 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
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
# 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:
@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:
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.
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:
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-toGustavo Fonseca
10/26/2025, 11:02 PMMarvin
10/26/2025, 11:02 PMMarvin
10/26/2025, 11:03 PMimport 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.Gustavo Fonseca
10/26/2025, 11:09 PMMarvin
10/26/2025, 11:09 PMMarvin
10/26/2025, 11:09 PMimport 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)
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.Gustavo Fonseca
11/05/2025, 12:05 AMMarvin
11/05/2025, 12:05 AMMarvin
11/05/2025, 12:10 AMextract_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:
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:
```Marvin
11/05/2025, 12:10 AM- Create the limit once (requires Prefect server/Cloud):
prefect concurrency-limit create camara-api 1
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
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):
...
- 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.Gustavo Fonseca
11/05/2025, 12:14 AMMarvin
11/05/2025, 12:14 AMMarvin
11/05/2025, 12:14 AMGustavo Fonseca
11/05/2025, 12:51 AMMarvin
11/05/2025, 12:51 AMMarvin
11/05/2025, 12:52 AMdetalhes_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)
# 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
# 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.
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.Gustavo Fonseca
11/05/2025, 11:47 PMMarvin
11/05/2025, 11:47 PMMarvin
11/05/2025, 11:52 PMfetch_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):
@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)Marvin
11/05/2025, 11:52 PMretry_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.Gustavo Fonseca
11/15/2025, 5:18 PMMarvin
11/15/2025, 5:18 PMMarvin
11/15/2025, 5:22 PMkeep_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:
[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:
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`:
@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:
@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:
[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 profilesMarvin
11/15/2025, 5:22 PMGustavo Fonseca
11/15/2025, 5:46 PMMarvin
11/15/2025, 5:46 PMMarvin
11/15/2025, 5:48 PMlog_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`:
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.
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):
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`:
@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:
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:
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:
prefect profile use <your-profile-name>Marvin
11/15/2025, 5:48 PMprefect version). If itâs not current, upgrade â there have been fixes around logging and print capture:
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.Gustavo Fonseca
11/15/2025, 5:59 PMcache_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.zipMarvin
11/15/2025, 6:00 PMMarvin
11/15/2025, 6:00 PMfrom 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:
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
@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 concurrentlyGustavo Fonseca
11/15/2025, 6:02 PMMarvin
11/15/2025, 6:02 PMMarvin
11/15/2025, 6:02 PMkeep_only_files.submit(tse_data_path, "csv", wait_for=tse_fs)
# flow can continue to submit other tasks here
- If you call:
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:
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):
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 concurrentlyGustavo Fonseca
12/13/2025, 4:50 PMMarvin
12/13/2025, 4:50 PMMarvin
12/13/2025, 4:55 PMpipeline 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
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):
@other_decorator
@flow
def pipeline(...):
...
Good:
@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
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:
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.Gustavo Fonseca
12/13/2025, 5:29 PMMarvin
12/13/2025, 5:29 PMMarvin
12/13/2025, 5:35 PM.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:
prefect deployment run 'pipeline/deploy-1'
How to get the UI back
1) Start a dedicated server in one terminal
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:
export PREFECT_API_URL=<http://127.0.0.1:4200/api>
uv run src/main.py
- Windows (PowerShell):
$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:
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.Gustavo Fonseca
12/25/2025, 3:00 PMMarvin
12/25/2025, 3:00 PMMarvin
12/25/2025, 3:04 PMtargets)
- 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)
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)
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 dependenciesMarvin
12/25/2025, 3:04 PMwait_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.Gustavo Fonseca
12/27/2025, 6:19 PMMarvin
12/27/2025, 6:19 PMMarvin
12/27/2025, 6:20 PM-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):
# 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):
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:
prefect deployment run --helpGustavo Fonseca
12/27/2025, 7:04 PMMarvin
12/27/2025, 7:04 PMMarvin
12/27/2025, 7:07 PM@task decorators have created Task objects.
- Example for a single module:
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:
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).
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.Gustavo Fonseca
12/29/2025, 6:58 PMMarvin
12/29/2025, 6:58 PMMarvin
12/29/2025, 7:00 PMresolve_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:
# 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.
# 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.Gustavo Fonseca
12/29/2025, 8:07 PMGustavo Fonseca
12/29/2025, 8:07 PMMarvin
12/29/2025, 8:07 PMMarvin
12/29/2025, 8:09 PM-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:
uv run prefect deployment run 'pipeline/deploy-1' \
-p ignore_tasks='["extract_camara_despesas_deputados"]' \
--watch
- Cross-shell (often simplest):
uv run prefect deployment run 'pipeline/deploy-1' \
--params '{"ignore_tasks":["extract_camara_despesas_deputados"]}' \
--watch
- PowerShell:
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:
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?Gustavo Fonseca
02/12/2026, 7:55 PMMarvin
02/12/2026, 7:55 PMMarvin
02/12/2026, 7:58 PMfrom 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:
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:
@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.Gustavo Fonseca
02/12/2026, 8:13 PMMarvin
02/12/2026, 8:13 PMMarvin
02/12/2026, 8:18 PMfrom 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:
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:
@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 failGustavo Fonseca
02/12/2026, 8:56 PMMarvin
02/12/2026, 8:56 PMMarvin
02/12/2026, 9:03 PM.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:
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)
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
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
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
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
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.Gustavo Fonseca
02/12/2026, 9:23 PMMarvin
02/12/2026, 9:23 PMMarvin
02/12/2026, 9:25 PMextract_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/cancelMarvin
02/12/2026, 9:25 PMWhy 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).Gustavo Fonseca
02/13/2026, 2:44 PMMarvin
02/13/2026, 2:44 PMMarvin
02/13/2026, 2:47 PMfrom 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.
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.Gustavo Fonseca
02/13/2026, 3:47 PMMarvin
02/13/2026, 3:47 PMMarvin
02/13/2026, 3:54 PMwait() 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.
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.
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.
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
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 behaviorMarvin
02/13/2026, 3:54 PMfetch_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.Gustavo Fonseca
02/13/2026, 3:56 PMMarvin
02/13/2026, 3:56 PMMarvin
02/13/2026, 4:04 PMqueue.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:
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:
prefect config set PREFECT_LOGGING_TO_API_ENABLED=false
- Or env var: PREFECT_LOGGING_TO_API_ENABLED=false
- Please share:
- Output of
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).Gustavo Fonseca
02/13/2026, 4:07 PMMarvin
02/13/2026, 4:07 PMGustavo Fonseca
02/13/2026, 4:10 PMMarvin
02/13/2026, 4:10 PMMarvin
02/13/2026, 4:11 PMinsert_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:Marvin
02/13/2026, 4:11 PMHow 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.Gustavo Fonseca
02/13/2026, 4:16 PMMarvin
02/13/2026, 4:16 PMMarvin
02/13/2026, 4:19 PMfrom 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:
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.Gustavo Fonseca
02/13/2026, 8:39 PMMarvin
02/13/2026, 8:39 PMMarvin
02/13/2026, 8:43 PMversion: 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):
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:
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.Gustavo Fonseca
02/13/2026, 8:46 PMGustavo Fonseca
02/13/2026, 8:47 PMMarvin
02/13/2026, 8:47 PMMarvin
02/13/2026, 8:48 PMversion: 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:
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
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)?Gustavo Fonseca
02/13/2026, 11:59 PMMarvin
02/14/2026, 12:00 AMMarvin
02/14/2026, 12:03 AMversion: 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:
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:
export PREFECT_LOGGING_HANDLERS_ERRORS_FILE_FILENAME=/var/log/prefect/my_custom_name.log
- Change retention days:
export PREFECT_LOGGING_HANDLERS_ERRORS_FILE_BACKUPCOUNT=14
- Change min level (e.g., CRITICAL only):
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:
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.Gustavo Fonseca
02/14/2026, 12:06 AMexport PREFECT_LOGGING_SETTINGS_PATH=/path/to/logging.yml
Where shoud i import this? I have put the file in ./src/config/logging.ymlMarvin
02/14/2026, 12:06 AMMarvin
02/14/2026, 12:09 AMexport PREFECT_LOGGING_SETTINGS_PATH="$(pwd)/src/config/logging.yml"
python your_flow.py
- Persist for your current Prefect profile
prefect config set PREFECT_LOGGING_SETTINGS_PATH="$(pwd)/src/config/logging.yml"
- Windows (PowerShell)
$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
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 loggingGustavo Fonseca
02/14/2026, 12:16 AMMarvin
02/14/2026, 12:16 AMMarvin
02/14/2026, 12:17 AMPREFECT_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 loggingGustavo Fonseca
02/14/2026, 12:23 AMMarvin
02/14/2026, 12:23 AMMarvin
02/14/2026, 12:23 AMversion: 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:
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):
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 loggingGustavo Fonseca
02/14/2026, 12:26 AMMarvin
02/14/2026, 12:26 AMMarvin
02/14/2026, 12:26 AMexport 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
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:
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 loggingGustavo Fonseca
02/19/2026, 3:36 PMMarvin
02/19/2026, 3:36 PMMarvin
02/19/2026, 3:38 PM-p/--param flags or a single --params JSON object.
- Individual params (repeat -p/--param). Values are parsed as JSON:
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`:
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 -):
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).Gustavo Fonseca
02/20/2026, 6:13 PMMarvin
02/20/2026, 6:13 PMMarvin
02/20/2026, 6:20 PMprefect 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
# 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):
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)
# 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).Gustavo Fonseca
02/20/2026, 6:33 PM# 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?Marvin
02/20/2026, 6:33 PMMarvin
02/20/2026, 6:34 PMprefect flow-run logs. Youâve got two practical options:
1) Keep your CLI loop and filter by level names with grep
# 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
# 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:
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).Gustavo Fonseca
02/20/2026, 6:45 PM# 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?Marvin
02/20/2026, 6:45 PMMarvin
02/20/2026, 6:46 PM--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:
# 1000 most recent runs
uv run prefect flow-run ls --limit 1000 -o json
You can also narrow which runs you list:
# 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:
uv run prefect flow-run ls --helpGustavo Fonseca
02/20/2026, 6:48 PMMarvin
02/20/2026, 6:48 PMMarvin
02/20/2026, 6:54 PMLATEST=$(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:
# 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).
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())
NotesMarvin
02/20/2026, 6:54 PMprefect 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.Gustavo Fonseca
02/20/2026, 7:06 PMMarvin
02/20/2026, 7:06 PMMarvin
02/20/2026, 7:08 PMdeployment_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
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
# 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:
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.Gustavo Fonseca
02/20/2026, 7:13 PMMarvin
02/20/2026, 7:13 PMMarvin
02/20/2026, 7:14 PMuv 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)
uv run prefect deployment ls -o json | jq '.[] | select(.id=="<DEPLOYMENT_ID>")'
- Flow ID and flow name (if available in your output)
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.Gustavo Fonseca
02/20/2026, 7:18 PMMarvin
02/20/2026, 7:18 PMMarvin
02/20/2026, 7:19 PMprefect 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
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.Gustavo Fonseca
02/20/2026, 7:20 PMuv run prefect deployment ls -o json | jq -r '.[] | select(.id=="<DEPLOYMENT_ID>") | .flow_id'
No such option: -oMarvin
02/20/2026, 7:20 PMMarvin
02/20/2026, 7:21 PMprefect --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:
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)
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:
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:
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.)Gustavo Fonseca
02/20/2026, 7:41 PMMarvin
02/20/2026, 7:41 PMMarvin
02/20/2026, 7:41 PMparent_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/<ACCOUNT_ID>/workspaces/<WORKSPACE_ID>"
- export PREFECT_API_KEY="..."
- Local server:
- BASE_URL="http://127.0.0.1:4200/api"
A) Start from deployment ID â latest parent run â children
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)
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.Gustavo Fonseca
02/20/2026, 8:23 PMMarvin
02/20/2026, 8:23 PMMarvin
02/20/2026, 8:23 PMfrom 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.Gustavo Fonseca
02/20/2026, 10:57 PMMarvin
02/20/2026, 10:57 PMMarvin
02/20/2026, 10:58 PMuv 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
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
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.Gustavo Fonseca
02/20/2026, 11:00 PMMarvin
02/20/2026, 11:00 PMMarvin
02/20/2026, 11:01 PMfrom 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
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.Gustavo Fonseca
02/20/2026, 11:03 PMMarvin
02/20/2026, 11:03 PMMarvin
02/20/2026, 11:04 PMimport 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:
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.Gustavo Fonseca
02/20/2026, 11:05 PMMarvin
02/20/2026, 11:05 PMMarvin
02/20/2026, 11:06 PMlog_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:
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:
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:
import prefect; print(prefect.__version__)Gustavo Fonseca
02/20/2026, 11:15 PMMarvin
02/20/2026, 11:15 PMMarvin
02/20/2026, 11:16 PMimport 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.Gustavo Fonseca
02/20/2026, 11:21 PMMarvin
02/20/2026, 11:22 PMMarvin
02/20/2026, 11:23 PMimport 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:
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)Gustavo Fonseca
02/20/2026, 11:26 PMMarvin
02/20/2026, 11:26 PMMarvin
02/20/2026, 11:27 PMimport 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:
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.Gustavo Fonseca
02/20/2026, 11:31 PMMarvin
02/20/2026, 11:31 PMMarvin
02/20/2026, 11:31 PMimport 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:
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.Gustavo Fonseca
02/20/2026, 11:35 PMMarvin
02/20/2026, 11:35 PMMarvin
02/20/2026, 11:36 PMimport 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.Gustavo Fonseca
02/20/2026, 11:38 PMMarvin
02/20/2026, 11:38 PMMarvin
02/20/2026, 11:39 PMimport 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:
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.Gustavo Fonseca
02/20/2026, 11:41 PMMarvin
02/20/2026, 11:41 PMMarvin
02/20/2026, 11:41 PMflow_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):
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):
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):
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.Gustavo Fonseca
02/20/2026, 11:46 PMMarvin
02/20/2026, 11:46 PMMarvin
02/20/2026, 11:46 PMimport 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.Gustavo Fonseca
02/20/2026, 11:49 PMMarvin
02/20/2026, 11:49 PMMarvin
02/20/2026, 11:50 PMimport 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:
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.Gustavo Fonseca
02/20/2026, 11:51 PMMarvin
02/20/2026, 11:51 PMMarvin
02/20/2026, 11:52 PMimport 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.Gustavo Fonseca
02/21/2026, 12:02 AMMarvin
02/21/2026, 12:02 AMMarvin
02/21/2026, 12:02 AMimport 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.Gustavo Fonseca
02/21/2026, 12:03 AMMarvin
02/21/2026, 12:03 AMMarvin
02/21/2026, 12:03 AMID=<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)
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.Gustavo Fonseca
02/21/2026, 12:13 AMMarvin
02/21/2026, 12:13 AMMarvin
02/21/2026, 12:14 AMimport 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.Gustavo Fonseca
02/21/2026, 12:17 AMMarvin
02/21/2026, 12:17 AMMarvin
02/21/2026, 12:17 AM# 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:
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.Gustavo Fonseca
02/21/2026, 12:24 AMMarvin
02/21/2026, 12:25 AMMarvin
02/21/2026, 12:25 AMimport 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):
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.Gustavo Fonseca
02/21/2026, 12:42 AMGustavo Fonseca
02/21/2026, 12:43 AMMarvin
02/21/2026, 12:43 AMMarvin
02/21/2026, 12:43 AMflow_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)
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
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):
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.âGustavo Fonseca
02/21/2026, 12:53 AMMarvin
02/21/2026, 12:53 AMMarvin
02/21/2026, 12:54 AMfrom 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.Gustavo Fonseca
02/21/2026, 7:28 PMMarvin
02/21/2026, 7:28 PMMarvin
02/21/2026, 7:31 PMfrom 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)
# 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:
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.Gustavo Fonseca
02/21/2026, 7:34 PMMarvin
02/21/2026, 7:34 PMMarvin
02/21/2026, 7:36 PMlote_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.
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:
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:
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:
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.Gustavo Fonseca
02/21/2026, 8:17 PMGustavo Fonseca
02/21/2026, 8:18 PMGustavo Fonseca
02/21/2026, 8:18 PMGustavo Fonseca
02/21/2026, 8:19 PMMarvin
02/21/2026, 8:58 PMMarvin
02/21/2026, 8:59 PMtask_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):
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' - âŚâ.Marvin
02/21/2026, 9:02 PMMarvin
02/21/2026, 9:02 PMMarvin
02/21/2026, 9:02 PMfrom 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.Marvin
02/21/2026, 9:02 PMtask_run_id to look up the task run name, then format your line.
Example (async), resolving all task names in one bulk call:
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:
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.Marvin
02/21/2026, 9:05 PMMarvin
02/21/2026, 9:06 PMfrom 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.Gustavo Fonseca
02/23/2026, 1:51 PMMarvin
02/23/2026, 1:51 PMMarvin
02/23/2026, 1:52 PMGustavo Fonseca
02/23/2026, 1:53 PMMarvin
02/23/2026, 1:53 PMMarvin
02/23/2026, 1:54 PMuv run prefect config view --hide-defaults --show-sources
- Filter to just those coming from environment variables (macOS/Linux)
uv run prefect config view --hide-defaults --show-sources | grep -i "env"
- Same filter in PowerShell (Windows)
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_*' }Gustavo Fonseca
02/23/2026, 2:01 PMMarvin
02/23/2026, 2:01 PMMarvin
02/23/2026, 2:20 PMuv run prefect flow serve path/to/file.py:your_flow --name your-deployment
Once serving, you can trigger a run from another terminal with:
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.Gustavo Fonseca
02/23/2026, 7:43 PMMarvin
02/23/2026, 7:43 PMMarvin
02/23/2026, 7:44 PMfrom 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
@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
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.Gustavo Fonseca
02/23/2026, 7:47 PMMarvin
02/23/2026, 7:47 PMMarvin
02/23/2026, 7:47 PMfrom 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.Gustavo Fonseca
02/23/2026, 7:48 PMPREFECT_RESULTS_PERSIST_BY_DEFAULT
i set this thing. it will persist all of my results?Marvin
02/23/2026, 7:48 PMMarvin
02/23/2026, 7:49 PMpersist_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.Gustavo Fonseca
02/23/2026, 8:07 PMMarvin
02/23/2026, 8:07 PMMarvin
02/23/2026, 8:08 PMpersist_result=True on the task (and add a cache policy if you want caching):
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):
@task(persist_result=False)
def big_payload_task(...):
...
- You can also override per-call:
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:
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)Gustavo Fonseca
02/24/2026, 5:04 PMMarvin
02/24/2026, 5:04 PMMarvin
02/24/2026, 5:04 PMload_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
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
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
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):
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.Gustavo Fonseca
03/11/2026, 7:21 PMMarvin
03/11/2026, 7:22 PMMarvin
03/11/2026, 7:26 PMprefect 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):
# 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:
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:
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?Gustavo Fonseca
03/11/2026, 7:59 PMMarvin
03/11/2026, 7:59 PMMarvin
03/11/2026, 8:01 PM--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
uv run prefect server start --keep-alive-timeout 1800
Or via env var:
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.Gustavo Fonseca
03/11/2026, 8:03 PMMarvin
03/11/2026, 8:03 PMMarvin
03/11/2026, 8:08 PM--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:
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`:
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).Gustavo Fonseca
03/11/2026, 8:14 PMMarvin
03/11/2026, 8:14 PMMarvin
03/11/2026, 8:14 PMprefect server status command.
Use an HTTP health/readiness check instead (replace host/port if you changed them):
- Unix/macOS:
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:
(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:
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:
prefect server stopGustavo Fonseca
03/11/2026, 8:30 PMMarvin
03/11/2026, 8:30 PMMarvin
03/11/2026, 8:32 PM--watch on the run, and start the server in the background so you can stop it after the run finishes.
Bash (Linux/macOS):
# 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:
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):
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).Gustavo Fonseca
03/11/2026, 9:00 PMtime.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 timeoutMarvin
03/11/2026, 9:00 PMMarvin
03/11/2026, 9:04 PM@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:
grep -R "timeout_seconds" .
grep -R ".with_options(" .
- Or quickly inspect in Python:
print(extract_camara_legislaturas.timeout_seconds)
2) If you donât want a timeout
- Remove the argument or reset it:
@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:
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:
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.Gustavo Fonseca
03/11/2026, 9:08 PMMarvin
03/11/2026, 9:08 PMMarvin
03/11/2026, 9:10 PM@flow decoratorâs timeout_seconds parameter.
Example:
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).Gustavo Fonseca
03/12/2026, 4:37 PMMarvin
03/12/2026, 4:37 PMMarvin
03/12/2026, 4:40 PMfrom 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.
@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.
b_f = task_b.submit(wait_for=[a_f])
4) Map with return_state and filter successes
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.