Chalama Reddy
04/13/2026, 3:35 PMMarvin
04/13/2026, 3:35 PMMarvin
04/13/2026, 3:39 PM/api/events/out. It should stay connected and receive messages. If it frequently reconnects and you’re behind a load balancer without sticky sessions, you’ll see delayed/missing live updates.
- On your API pods, enable DEBUG logs and look for event persister messages about flushes and queue capacity.
Mitigations and tuning
- Reduce event batching latency (on your API pods):
- PREFECT_SERVER_SERVICES_EVENT_PERSISTER_FLUSH_INTERVAL=1 (default is 5)
- PREFECT_SERVER_SERVICES_EVENT_PERSISTER_READ_BATCH_SIZE=10
- Optionally increase throughput: PREFECT_SERVER_SERVICES_EVENT_PERSISTER_BATCH_SIZE=50-100 and PREFECT_SERVER_SERVICES_EVENT_PERSISTER_QUEUE_MAX_SIZE=100000
- Ensure full HA wiring (critical):
- Use Redis for messaging (not in‑memory): PREFECT_MESSAGING_BROKER=prefect_redis.messaging and set PREFECT_REDIS_MESSAGING_URL=redis://...
- Make sure Docket uses Redis (not memory): PREFECT_SERVER_DOCKET_URL=redis://...
- Configure sticky sessions for your load balancer so WebSockets consistently hit the same API instance, and be sure to enable the Upgrade/Connection headers for WS.
- If you still see gaps after tasks are clearly RUNNING, consider upgrading to the latest 3.x — there have been fixes that improve graph visibility and event timing since 3.6.23.
If you can share a bit more, I can help pinpoint the exact bottleneck:
- Approximate delay you observe (e.g., 3–10s vs 30–60s)?
- Load balancer type and whether sticky sessions are enabled?
- Values (if any) you’ve set for:
- PREFECT_MESSAGING_BROKER, PREFECT_REDIS_MESSAGING_URL
- PREFECT_SERVER_DOCKET_URL
- Event persister envs (flush interval, read/batch sizes)
- A snippet of worker logs around a run that shows when the first task transitions to RUNNING.
In short: part of the delay is expected (nothing shows while everything is still PENDING), but you can usually trim the rest by tuning event persister settings and ensuring sticky WebSockets + Redis-backed services in HA.Chalama Reddy
04/13/2026, 3:41 PMMarvin
04/13/2026, 3:41 PMMarvin
04/13/2026, 3:44 PMflow_run.start_time, flow_run.expected_start_time, task_run.start_time, task_run.expected_start_time. Very short/cached tasks or subflows can complete without a RUNNING phase and end up with null start_time. Those nodes won’t show in the graph until timing fields are present, which can look like a delay.
- UI refresh path in HA: The initial graph is fetched via REST, but the UI also relies on real-time updates. In HA with Redis and a load balancer, if WebSocket connections aren’t sticky or events are batched, the UI may not refresh immediately after completion — you’ll see the graph “catch up” a bit later when the next refetch happens.
- Event batching: The event persister batches (default flush ~5s). While the graph is DB-driven, other UI panels and triggers for refetching can be delayed if events land a few seconds later.
- Clock skew/timezone edges: If any client/UI code or intermediaries apply a “since” filter with a time near “now”, skew between browser, API servers, and DB can exclude just-finished tasks until a subsequent refetch. Make sure all servers are NTP-synced.
Fast way to pinpoint where the delay comes from
1) Hit the graph endpoint directly right after the run completes (bypasses UI/WebSocket):
curl -sS -H "Authorization: Bearer <TOKEN>" \
"<YOUR_API_URL>/api/flow_runs/<FLOW_RUN_ID>/graph-v2?since=0001-01-01T00:00:00Z" \
| jq '.nodes | length, .edges | length'
- If this returns the expected nodes/edges immediately, the backend is fine and the delay is in UI/event refresh.
- If nodes are missing here, it’s likely the timing-field filter (e.g., null start_time/expected_start_time).
2) Compare against the task list for the same flow:
curl -sS -H "Authorization: Bearer <TOKEN>" \
"<YOUR_API_URL>/api/task_runs/filter" \
-H "Content-Type: application/json" \
-d '{"flow_runs":{"id":{"any_":["<FLOW_RUN_ID>"]}}}' \
| jq 'length'
- If the task list count > graph node count right after completion, you’re likely hitting the graph timing-field filter.
3) Confirm HA/WebSocket behavior
- Ensure sticky sessions at the load balancer for the API (WebSockets to /api/events/out must consistently hit the same API instance).
- In the browser dev tools, confirm the WS stays connected and receives messages immediately after completion.
4) Tune event batching (reduces visible delays in the UI)
- On your API pods:
- PREFECT_SERVER_SERVICES_EVENT_PERSISTER_FLUSH_INTERVAL=1
- PREFECT_SERVER_SERVICES_EVENT_PERSISTER_READ_BATCH_SIZE=10
- Optionally: PREFECT_SERVER_SERVICES_EVENT_PERSISTER_BATCH_SIZE=50-100, PREFECT_SERVER_SERVICES_EVENT_PERSISTER_QUEUE_MAX_SIZE=100000
Recommendations
- Verify the direct graph-v2 call with a “since” far in the past. If nodes are missing there right after completion, you’re almost certainly running into the timing-field filter. This is most visible on very short or cached tasks/subflows that don’t record a RUNNING start_time.
- Ensure HA wiring is fully Redis-backed and sticky sessions are enabled so the UI gets timely real-time updates.
- Consider upgrading to the latest 3.x — there have been fixes that improve event timing and graph-related behavior since 3.6.23.
- Make sure all API servers and your DB are time-synced (NTP); even small skew can cause “just-finished” items to be filtered by time-based queries in some views.
If you can share:
- How long the delay is (e.g., ~5–10s vs 30–60s),
- Whether the direct graph-v2 call shows nodes immediately,
- Your load balancer type and whether stickiness is enabled,
- Event persister env vars you’re using,
…I can help zero in on the exact bottleneck and suggest precise config changes.Chalama Reddy
04/13/2026, 3:50 PMChalama Reddy
04/13/2026, 3:50 PMMarvin
04/13/2026, 3:51 PMMarvin
04/13/2026, 3:53 PM/api/flow_runs/<id>/graph-v2 and /api/task_runs/filter. If you see Cache-Control with max‑age or s‑maxage around 900, that’s your culprit.
- Mitigation: Disable caching for the /api path at your proxy/CDN or set Cache-Control: no-store on those routes.
3) UI cache + missing real‑time invalidation (sticky sessions)
- The UI uses a client cache and real‑time events to invalidate and refetch. If WebSockets aren’t sticky across API pods, the client may miss invalidations and only refetch on a longer interval (often ~15m in some views).
- Test: After a flow completes, do a hard reload of the run details page. If the graph shows up immediately after a hard reload but not “live”, this is a WebSocket stickiness/invalidation issue.
- Ensure sticky sessions at the load balancer and that WebSocket upgrade headers are enabled for /api/events/out.
How to isolate the root cause quickly
1) Bypass the UI and hit the API immediately after a run completes
- Graph:
curl -sS -H "Authorization: Bearer <TOKEN>" \
"<YOUR_API_URL>/api/flow_runs/<FLOW_RUN_ID>/graph-v2?since=0001-01-01T00:00:00Z" \
| jq '.nodes | length, .edges | length'
- Task runs list:
curl -sS -H "Authorization: Bearer <TOKEN>" \
"<YOUR_API_URL>/api/task_runs/filter" \
-H "Content-Type: application/json" \
-d '{"flow_runs":{"id":{"any_":["<FLOW_RUN_ID>"]}}}' \
| jq 'length'
- Outcomes:
- If these show the expected data immediately, the backend is fine; look at WebSocket stickiness and/or HTTP caching at the proxy.
- If these are missing data right away, check your DB topology for replicas and confirm the API is reading from the writer.
2) Verify database topology
- Are you using RDS/Aurora with reader endpoints, or any read replicas?
- Is PREFECT_API_DATABASE_CONNECTION_URL pointing at a writer/primary endpoint?
- If using PgBouncer, try session pooling and ensure it routes reads to the writer (or a proxy that provides read‑after‑write consistency).
3) Check your reverse proxy/CDN
- Ensure there’s no caching for /api/*. If you’re using NGINX/CloudFront/Cloudflare, disable caching or set Cache-Control: no-store for API responses.
- Look for any microcaching configured on your NGINX location for /api.
4) Confirm HA/WebSocket setup
- Enable sticky sessions on your LB for the API so the WS to /api/events/out remains on the same instance.
- In browser dev tools, ensure the WS stays connected and receives events promptly when runs complete.
Notes
- Event persister batching (even at defaults) accounts for seconds, not 15+ minutes.
- Time skew can exacerbate issues but usually won’t create a consistent 15‑minute gap.
If you can share:
- Your DB connection string target (writer vs reader), and whether you use PgBouncer/DB proxiesMarvin
04/13/2026, 3:53 PMChalama Reddy
04/13/2026, 4:17 PMMarvin
04/13/2026, 4:17 PMMarvin
04/13/2026, 4:18 PM# Graph (should return nodes immediately upon completion)
curl -sS -H "Authorization: Bearer <TOKEN>" \
"<YOUR_API_URL>/api/flow_runs/<FLOW_RUN_ID>/graph-v2?since=0001-01-01T00:00:00Z" \
| jq '.nodes | length, .edges | length'
# Task runs list
curl -sS -H "Authorization: Bearer <TOKEN>" \
"<YOUR_API_URL>/api/task_runs/filter" \
-H "Content-Type: application/json" \
-d '{"flow_runs":{"id":{"any_":["<FLOW_RUN_ID>"]}}}' \
| jq 'length'
- If these show expected data immediately, the backend is fine; look for HTTP caching or WS stickiness issues.
- If these are missing right away and only show up 15+ minutes later, it’s almost certainly DB read-from-replica lag.
2) Check the DB directly on the writer/primary immediately after completion:
-- Replace with your IDs
SELECT id, name, state_type, start_time, end_time
FROM task_runs
WHERE flow_run_id = '<FLOW_RUN_ID>'
ORDER BY created DESC
LIMIT 20;
SELECT trs.task_run_id, trs.state_type, trs.timestamp
FROM task_run_states trs
JOIN task_runs tr ON tr.id = trs.task_run_id
WHERE tr.flow_run_id = '<FLOW_RUN_ID>'
ORDER BY trs.timestamp DESC
LIMIT 20;
- If these rows exist on the primary right away but not via your API/UI, your API is reading from a replica or a proxy that routes some reads to replicas.
3) Verify your DB connection target
- Ensure PREFECT_API_DATABASE_CONNECTION_URL points to the writer/primary endpoint (e.g., AWS RDS writer), not a reader endpoint or a read-balanced proxy.
- If using PgBouncer/DB proxies, prefer session pooling and ensure no read splitting that breaks read-after-write consistency.
4) Rule out HTTP caching on /api
- Check response headers for /api/flow_runs/<id>/graph-v2 and /api/task_runs/filter. There should be no Cache-Control max-age/s-maxage on those routes. Disable caching for /api/* in your LB/CDN.
5) WebSockets/sticky sessions
- Not the cause of data not being persisted, but lack of stickiness can delay UI invalidation. Enable sticky sessions and WS upgrade headers for /api/events/out.
If you can confirm:
- Your exact PREFECT_API_DATABASE_CONNECTION_URL target (writer vs reader/proxy),
- Whether the SQL above shows the rows on the primary right away,
- Any proxy/CDN in front of your API and its caching rules,
…I can help pinpoint the misconfiguration and suggest precise fixes.Chalama Reddy
04/13/2026, 4:32 PMChalama Reddy
04/13/2026, 4:32 PMMarvin
04/13/2026, 4:33 PMMarvin
04/13/2026, 4:33 PMChalama Reddy
04/13/2026, 4:33 PMMarvin
04/13/2026, 4:33 PMMarvin
04/13/2026, 4:34 PMprefect config view | grep PREFECT_API_DATABASE_CONNECTION_URL
- They must be identical across all API pods and point to the same writer/primary endpoint.
- If you have read replicas/proxies, make sure the URL is the writer (not a reader endpoint).
2) Verify worker -> API path
- On each worker pod:
prefect config view | grep PREFECT_API_URL
- Confirm workers point to the same API base URL your UI is using. If they point somewhere else (e.g., a different LB, a pod IP, or Cloud), they’ll write to a different API/DB than the one you’re inspecting.
3) Confirm messaging is truly shared (not in-memory)
- On ALL API pods (and anywhere background services run), verify:
prefect config view | grep -E "PREFECT_MESSAGING_BROKER|PREFECT_REDIS_MESSAGING_URL|PREFECT_SERVER_DOCKET_URL|PREFECT_SERVER_SERVICES_EVENT_PERSISTER_ENABLED|PREFECT_SERVER_EVENTS_STREAM_OUT_ENABLED"
- Recommended values in HA:
PREFECT_MESSAGING_BROKER=prefect_redis.messaging
PREFECT_REDIS_MESSAGING_URL=redis://<redis-host>:6379/0
PREFECT_SERVER_DOCKET_URL=redis://<redis-host>:6379/1
PREFECT_SERVER_SERVICES_EVENT_PERSISTER_ENABLED=true
PREFECT_SERVER_EVENTS_STREAM_OUT_ENABLED=true
- If any pod shows an in-memory/default broker or a different Redis DB/index/URL, events will be “published” but never seen by the persister/distributor.
4) Check logs on the API pods for background services
- You should see logs from the event persister and distributor starting up and connecting to Redis. If they’re missing or erroring, events will not persist.
- Also check that the API logs show incoming requests for:
- POST /api/task_runs/ (task run create)
- POST /api/task_runs/<id>/set_state (state transitions)
- If these do not appear during your run, the workers are not targeting this API.
5) Direct DB verification (on the writer/primary)
- Immediately after a run completes, run:
SELECT id, name, state_type, start_time, end_time
FROM task_runs
WHERE flow_run_id = '<FLOW_RUN_ID>'
ORDER BY created DESC
LIMIT 20;
SELECT trs.task_run_id, trs.state_type, trs.timestamp
FROM task_run_states trs
JOIN task_runs tr ON tr.id = trs.task_run_id
WHERE tr.flow_run_id = '<FLOW_RUN_ID>'
ORDER BY trs.timestamp DESC
LIMIT 20;
- If these rows are present on the primary, but the UI/API doesn’t show them, your API is reading from a different DB or a stale replica.
- If these rows are not present on the primary, the worker/API path is not writing to this database.
Why PR #21264 is not relevantMarvin
04/13/2026, 4:34 PMPOST /task_runs and .../set_state are received),
…I can help pinpoint exactly where the path breaks and give you the exact changes to make.Chalama Reddy
04/13/2026, 5:00 PMMarvin
04/13/2026, 5:00 PMMarvin
04/13/2026, 5:00 PMChalama Reddy
04/13/2026, 5:01 PMMarvin
04/13/2026, 5:02 PMMarvin
04/13/2026, 5:03 PMprefect config view | grep PREFECT_API_DATABASE_CONNECTION_URL
- Should be the same writer/primary endpoint for every API pod in both clusters.
- Messaging:
- prefect config view | grep -E "PREFECT_MESSAGING_BROKER|PREFECT_REDIS_MESSAGING_URL"
- Must be identical across clusters if you expect shared event handling.
- Docket (leader election):
- prefect config view | grep -E "PREFECT_SERVER_DOCKET_URL|PREFECT_SERVER_DOCKET_NAME"
- Must be identical across clusters if you want a single leader across all API instances.
- Services toggles:
- prefect config view | grep PREFECT_SERVER_SERVICES_Marvin
04/13/2026, 5:03 PMChalama Reddy
04/13/2026, 5:16 PMMarvin
04/13/2026, 5:16 PMMarvin
04/13/2026, 5:17 PMPREFECT_SERVER_DOCKET_URL
- PREFECT_SERVER_DOCKET_NAME (must match across clusters to elect a single leader)
- Messaging broker must be identical:
- PREFECT_MESSAGING_BROKER=prefect_redis.messaging
- PREFECT_REDIS_MESSAGING_URL exactly the same (host, port, db index, scheme, credentials, query params)
- Database URL must be identical and point to the writer:
- PREFECT_API_DATABASE_CONNECTION_URL on all API pods must target the same writer/primary endpoint (not a reader/replica).
- Time sync and network stability:
- All nodes in both clusters should be NTP‑synced.
- Latency/jitter to Redis can cause lease flapping; make sure both clusters have low‑latency, reliable paths to Redis.
Why you can still see deadlocks even with “same Postgres and Redis”
- If Docket name/URL differ even subtly (different Redis DB index or TLS scheme), each cluster will elect its own leader. Two schedulers/automations will compete on the same rows, which can produce Postgres “deadlock detected”.
- If Redis connectivity is flaky from one cluster, that side may frequently “lose and re‑acquire” the lease, causing leadership thrash and more contention.
“Events published but background service didn’t receive”
- If any API instance is using a different messaging backend (e.g., default in‑memory) or a different Redis DB index, you’ll see “published” at the publisher but nothing consumed by the persister/distributor in the other cluster.
- Also confirm the event services are actually enabled on the API pods that should be consuming:
- PREFECT_SERVER_SERVICES_EVENT_PERSISTER_ENABLED=true
- PREFECT_SERVER_EVENTS_STREAM_OUT_ENABLED=true
- Startup logs on those pods should clearly show connection to the Redis broker and periodic “flushed N events” messages. If you don’t see those, the persister isn’t running/connected.
Recommended deployment pattern to stop the deadlocks now
- Run singleton services in only one cluster. On the “secondary” cluster, disable them:
PREFECT_SERVER_SERVICES_SCHEDULER_ENABLED=false
PREFECT_SERVER_SERVICES_AUTOMATIONS_ENABLED=false
PREFECT_SERVER_SERVICES_LATE_RUNS_DETECTOR_ENABLED=false
PREFECT_SERVER_SERVICES_DB_VACUUM_ENABLED=none
- Keep event services enabled only if they point to the same Redis broker and are stable. If you suspect broker differences or network instability, temporarily disable on one cluster to avoid confusion:
PREFECT_SERVER_SERVICES_EVENT_PERSISTER_ENABLED=true # enable on primary
PREFECT_SERVER_SERVICES_EVENT_PERSISTER_ENABLED=false # disable on secondary (temporarily)
PREFECT_SERVER_EVENTS_STREAM_OUT_ENABLED=true # primary
PREFECT_SERVER_EVENTS_STREAM_OUT_ENABLED=false # secondary (temporarily)
Run these quick checks on one API pod in each cluster
prefect config view | grep -E "PREFECT_API_DATABASE_CONNECTION_URL|PREFECT_MESSAGING_BROKER|PREFECT_REDIS_MESSAGING_URL|PREFECT_SERVER_DOCKET_URL|PREFECT_SERVER_DOCKET_NAME|PREFECT_SERVER_SERVICES_"
- Paste the outputs (redact secrets) and I’ll spot any inconsistencies.
Operational tips
- Watch logs for “acquired lease” messages for scheduler/automations. You should see them only from one cluster if Docket is truly shared.
- Check Redis keyspace/DB index: a common gotcha is one cluster using /0 and the other /1.
- Ensure your load balancer keeps WebSockets sticky for /api/events/out, but note this is about live UI updates — it won’t fix missing DB rows.Marvin
04/13/2026, 5:17 PMChalama Reddy
04/14/2026, 1:15 AMMarvin
04/14/2026, 1:15 AMMarvin
04/14/2026, 1:16 AMPREFECT_SERVER_SERVICES_EVENT_PERSISTER_ENABLED=true on all of them.
- Keep singleton services (scheduler, automations, late-run detector) coordinated with a single shared Docket or disable them on one cluster to avoid deadlocks (details below).
- Tune the persister for throughput
- These settings have the biggest impact on catch-up under load:
- PREFECT_SERVER_SERVICES_EVENT_PERSISTER_READ_BATCH_SIZE=100 (or higher)
- PREFECT_SERVER_SERVICES_EVENT_PERSISTER_BATCH_SIZE=200-500
- PREFECT_SERVER_SERVICES_EVENT_PERSISTER_FLUSH_INTERVAL=1-2
- PREFECT_SERVER_SERVICES_EVENT_PERSISTER_QUEUE_MAX_SIZE=100000-200000
- Validate Postgres can absorb the write volume (connection pool/proxy, IOPS, WAL throughput). If using PgBouncer, use session pooling for write-heavy workloads and raise pool sizes accordingly.
- Keep Redis and Docket shared across all API instances
- Messaging broker (events):
- PREFECT_MESSAGING_BROKER=prefect_redis.messaging
- PREFECT_REDIS_MESSAGING_URL=redis://<redis-host>:6379/<db>
- Docket (leader election for singleton services):
- PREFECT_SERVER_DOCKET_URL=redis://<redis-host>:6379/<db>
- PREFECT_SERVER_DOCKET_NAME=<same across all instances>
- If you run across two K8s clusters and don’t want to share Docket, disable singleton services on one cluster:
- PREFECT_SERVER_SERVICES_SCHEDULER_ENABLED=false
- PREFECT_SERVER_SERVICES_AUTOMATIONS_ENABLED=false
- PREFECT_SERVER_SERVICES_LATE_RUNS_DETECTOR_ENABLED=false
- PREFECT_SERVER_SERVICES_DB_VACUUM_ENABLED=none
- Observe and verify
- Enable DEBUG logs on the API pods and watch for:
- Event persister flush cadence and batch sizes
- Warnings like “Event queue at X% capacity”
- The backlog should begin to drain immediately after scaling/tuning; the “delay until persisted” should drop from minutes to seconds.
- Note on flow/task run persistence
- Flow/task runs and their states are written synchronously by the API (not by the event persister). If you still see missing task_runs in Postgres, double-check worker → API routing and that all API pods point to the same writer database. The event persister backlog only impacts the events tables and event-driven features (e.g., automations, events UI), not the core run/state rows.
If you share:
- Your current event rate (approx flows/min, tasks/min),
- Current values for the four persister settings above,
- API replica count and DB/proxy setup,
…I can suggest concrete target values and a scaling plan to eliminate the 15+ minute lag.Chalama Reddy
04/14/2026, 3:39 AMMarvin
04/14/2026, 3:39 AMMarvin
04/14/2026, 3:40 AMPREFECT_API_DATABASE_CONNECTION_URL must point to the writer/primary (not a read replica or mixed read/write proxy).
- Same Redis broker for events on all API pods:
- PREFECT_MESSAGING_BROKER=prefect_redis.messaging
- PREFECT_REDIS_MESSAGING_URL=redis://<host>:6379/<db>
- Same Docket (leader election) on all API pods:
- PREFECT_SERVER_DOCKET_URL=redis://<host>:6379/<db>
- PREFECT_SERVER_DOCKET_NAME=<identical across all pods/clusters>
- If you span multiple Kubernetes clusters, network latency/jitter to Redis must be low and clocks NTP-synced; otherwise you can see lease flapping.
If you keep two K8s clusters
- Preferred: Run all API + background services in one “primary” cluster (multiple replicas), and run only workers in the “secondary” cluster(s).
- If you must run API/background services in both clusters:
- Ensure Docket URL/name and the Redis broker URL are exactly identical (host, port, DB index, TLS scheme, credentials).
- If you cannot guarantee shared/low-latency Docket, disable singleton services in one cluster:
PREFECT_SERVER_SERVICES_SCHEDULER_ENABLED=false
PREFECT_SERVER_SERVICES_AUTOMATIONS_ENABLED=false
PREFECT_SERVER_SERVICES_LATE_RUNS_DETECTOR_ENABLED=false
# Optional if you’ve enabled any cleanup services
PREFECT_SERVER_SERVICES_DB_VACUUM_ENABLED=none
- You can still scale the Event Persister across pods in the “primary” cluster to drain event backlogs quickly.
Throughput tuning for the Event Persister
- Scale API replicas up (each runs a persister instance).
- Increase batching and read sizes to drain the queue faster:
PREFECT_SERVER_SERVICES_EVENT_PERSISTER_ENABLED=true
PREFECT_SERVER_SERVICES_EVENT_PERSISTER_READ_BATCH_SIZE=100
PREFECT_SERVER_SERVICES_EVENT_PERSISTER_BATCH_SIZE=200-500
PREFECT_SERVER_SERVICES_EVENT_PERSISTER_FLUSH_INTERVAL=1-2
PREFECT_SERVER_SERVICES_EVENT_PERSISTER_QUEUE_MAX_SIZE=100000-200000
- Ensure Postgres (and any pooler like PgBouncer) can handle the write concurrency.
Operational checks
- On at least one API pod per cluster:
prefect config view | grep -E "PREFECT_API_DATABASE_CONNECTION_URL|PREFECT_MESSAGING_BROKER|PREFECT_REDIS_MESSAGING_URL|PREFECT_SERVER_DOCKET_URL|PREFECT_SERVER_DOCKET_NAME|PREFECT_SERVER_SERVICES_"
- All URLs/names should match exactly (including Redis DB index and rediss:// vs redis://).
- Logs:
- You should see a single “acquired lease” for singleton services across all pods/clusters.
- Event persister logs should show frequent flushes; if you see “Event queue at X% capacity,” increase concurrency/queue limits or add replicas.
- For UI real-time updates, enable sticky sessions and WebSocket upgrade headers on your load balancer for /api/events/out.
Bottom line
- Yes — with Docket in 3.6.24, you can (and should) run multiple background service instances for HA and to handle heavy event load.Marvin
04/14/2026, 3:40 AMChalama Reddy
04/14/2026, 3:48 AMprefect server services start --safe-only # only horizontally scalable services
prefect server services start --singleton-only # services requiring single instanceMarvin
04/14/2026, 3:48 AMMarvin
04/14/2026, 3:52 AMprefect server services start --safe-only → invalid
- prefect server services start --singleton-only → invalid
The only options on that command are:
- --background (or -b)
- --help
How to control which services start
Use environment variables to enable/disable individual services before running `prefect server services start`:
Common toggles
- Scheduler: PREFECT_SERVER_SERVICES_SCHEDULER_ENABLED=false
- Late runs detector: PREFECT_SERVER_SERVICES_LATE_RUNS_ENABLED=false
- Automations/Triggers: PREFECT_SERVER_SERVICES_TRIGGERS_ENABLED=false
- Event persister: PREFECT_SERVER_SERVICES_EVENT_PERSISTER_ENABLED=true ← scale this up for your backlog
- Task run recorder: PREFECT_SERVER_SERVICES_TASK_RUN_RECORDER_ENABLED=true
- Cancellation cleanup: PREFECT_SERVER_SERVICES_CANCELLATION_CLEANUP_ENABLED=false
- DB vacuum/cleanup:
- PREFECT_SERVER_SERVICES_DB_VACUUM_ENABLED=events (default)
- or flow_runs, or events,flow_runs, or none
Real-time streams
- Events WebSocket stream: PREFECT_SERVER_EVENTS_STREAM_OUT_ENABLED=true
- Logs WebSocket stream: PREFECT_SERVER_LOGS_STREAM_OUT_ENABLED=false (usually off)
Tips for your use case (heavy events backlog)
- Scale out by running more API replicas; each runs an event persister instance.
- Tune throughput:
- PREFECT_SERVER_SERVICES_EVENT_PERSISTER_READ_BATCH_SIZE=100
- PREFECT_SERVER_SERVICES_EVENT_PERSISTER_BATCH_SIZE=200-500
- PREFECT_SERVER_SERVICES_EVENT_PERSISTER_FLUSH_INTERVAL=1-2
- PREFECT_SERVER_SERVICES_EVENT_PERSISTER_QUEUE_MAX_SIZE=100000-200000
- Keep singleton services safe across clusters with Docket; or disable them on one cluster entirely as needed.
If you want, share the list from:
prefect server services ls
and the relevant env values you’re using, and I’ll suggest a minimal set to run per cluster.Chalama Reddy
04/14/2026, 3:56 AMMarvin
04/14/2026, 3:56 AMMarvin
04/14/2026, 3:58 AMPREFECT_SERVER_SERVICES_TASK_RUN_RECORDER_ENABLED=true
- PREFECT_SERVER_SERVICES_TASK_RUN_RECORDER_READ_BATCH_SIZE=50-200
- PREFECT_SERVER_SERVICES_TASK_RUN_RECORDER_BATCH_SIZE=50-200
- PREFECT_SERVER_SERVICES_TASK_RUN_RECORDER_FLUSH_INTERVAL=1-2
- Start with 50/50/1 and scale up based on DB capacity. If Postgres (or PgBouncer) can handle bigger transactions, move toward 100–200 for both batch sizes.
- Monitor API logs for insert throughput and any warnings/errors; adjust if you see lock contention or slow commits.
Scale horizontally
- It’s safe to run multiple Task Run Recorder instances on multiple API pods. They join a consumer group and split work; duplicate processing is guarded by upsert/unique constraints.
- Simply scale your API Deployment replicas; keep the same settings on each pod.
Also tune Event Persister if you’re seeing event backlogs
- PREFECT_SERVER_SERVICES_EVENT_PERSISTER_READ_BATCH_SIZE=50-200
- PREFECT_SERVER_SERVICES_EVENT_PERSISTER_BATCH_SIZE=100-500
- PREFECT_SERVER_SERVICES_EVENT_PERSISTER_FLUSH_INTERVAL=1-2
- PREFECT_SERVER_SERVICES_EVENT_PERSISTER_QUEUE_MAX_SIZE=100000-200000
Sanity checks
- All API pods must share the same Redis broker URL and the same Postgres writer.
- Verify with:
- prefect config view | grep PREFECT_REDIS_MESSAGING_URL
- prefect config view | grep PREFECT_API_DATABASE_CONNECTION_URL
- Confirm the service is enabled:
- prefect server services ls (look for task_run_recorder: enabled/running)
If you share your current values and approximate event volume (task-run events/sec), I can suggest precise batch sizes to bring the delay down to ~1–2s.Chalama Reddy
04/14/2026, 5:04 AMCancellationCleanup
• Foreman
• MarkLateRuns
• FailExpiredPauses
• Repossessor
• RecentDeploymentsScheduler
• Scheduler
• TaskRunRecorder
• Telemetry
• ProactiveTriggers
• ReactiveTriggers
• ActionsMarvin
04/14/2026, 5:04 AMMarvin
04/14/2026, 5:07 AMPREFECT_SERVER_SERVICES_SCHEDULER_ENABLED=false
PREFECT_SERVER_SERVICES_TRIGGERS_ENABLED=false
PREFECT_SERVER_SERVICES_LATE_RUNS_ENABLED=false
PREFECT_SERVER_SERVICES_CANCELLATION_CLEANUP_ENABLED=false
PREFECT_SERVER_SERVICES_DB_VACUUM_ENABLED=none
# And any others you’ve enabled (e.g., Foreman, ProactiveTriggers, Repossessor)
Summary
- Multi-instance, throughput-scaling: TaskRunRecorder, ReactiveTriggers, Actions
- Multi-instance, leader-elected (one active at a time via Docket): all the rest (including Scheduler)
- Across multiple clusters: fully supported if you share the same Redis broker and the same Redis-backed Docket (URL + name) and point all instances at the same Postgres writer.Chalama Reddy
04/14/2026, 5:12 AMMarvin
04/14/2026, 5:12 AMMarvin
04/14/2026, 5:12 AM# Task Run Recorder
PREFECT_SERVER_SERVICES_TASK_RUN_RECORDER_ENABLED=true
PREFECT_SERVER_SERVICES_TASK_RUN_RECORDER_READ_BATCH_SIZE=50-200
PREFECT_SERVER_SERVICES_TASK_RUN_RECORDER_BATCH_SIZE=50-200
PREFECT_SERVER_SERVICES_TASK_RUN_RECORDER_FLUSH_INTERVAL=1-2
# Event Persister (often the other bottleneck)
PREFECT_SERVER_SERVICES_EVENT_PERSISTER_ENABLED=true
PREFECT_SERVER_SERVICES_EVENT_PERSISTER_READ_BATCH_SIZE=50-200
PREFECT_SERVER_SERVICES_EVENT_PERSISTER_BATCH_SIZE=100-500
PREFECT_SERVER_SERVICES_EVENT_PERSISTER_FLUSH_INTERVAL=1-2
PREFECT_SERVER_SERVICES_EVENT_PERSISTER_QUEUE_MAX_SIZE=100000-200000
- Start at 50/50/1 for recorder, 100/200/1 for persister; raise gradually if Postgres and Redis keep up.
2) Keep singleton services enabled everywhere (Docket will ensure one leader)
- Scheduler, RecentDeploymentsScheduler, MarkLateRuns, CancellationCleanup, FailExpiredPauses, Foreman, ProactiveTriggers, Telemetry, Repossessor.
- Since you share the same Redis-backed Docket URL and name across clusters, only one instance will be active at any time. If you ever see leadership flapping (frequent acquire/release), it usually points to network latency/jitter to Redis.
3) Optional: centralize event-heavy services
- If cross-cluster network latency to Redis/DB is non-trivial, consider running higher replica counts of Task Run Recorder and Event Persister in the cluster closest (network-wise) to Redis/DB. Keep at least one replica in the other cluster for HA.
4) Verify at runtime
- On any API pod:
prefect server services ls
prefect config view | grep -E "PREFECT_API_DATABASE_CONNECTION_URL|PREFECT_REDIS_MESSAGING_URL|PREFECT_SERVER_DOCKET_URL|PREFECT_SERVER_DOCKET_NAME|PREFECT_SERVER_SERVICES_TASK_RUN_RECORDER|PREFECT_SERVER_SERVICES_EVENT_PERSISTER"
- In API logs, you should see frequent recorder/persister flushes and no “queue at X% capacity” warnings once tuned.
- Measure end-to-end: time from a task starting to when its state appears in Postgres should drop to ~1–2s with the above settings under load.
5) Postgres readiness
- Ensure your DB/pooler can handle the increased batch sizes and concurrent transactions (e.g., PgBouncer session pooling, higher max connections, adequate IOPS/WAL). If you see lock contention or slow commits, reduce batch_size slightly and add more replicas instead.
This setup should eliminate the 15+ minute lag you were seeing and keep latency close to seconds, even at high throughput. If you’d like, share your approximate task-run event rate and current recorder/persister settings, and I can suggest precise target values for your environment.Chalama Reddy
04/14/2026, 5:44 AMMarvin
04/14/2026, 5:45 AMMarvin
04/14/2026, 5:47 AMChalama Reddy
04/14/2026, 5:56 AMMarvin
04/14/2026, 5:56 AMMarvin
04/14/2026, 5:59 AMprefect config view | grep -E "PREFECT_SERVER_DOCKET_URL|PREFECT_SERVER_DOCKET_NAME"
- Values must be byte-for-byte identical.
2) Verify services are enabled
- prefect server services ls
- Ensure your singleton services (e.g., Scheduler, MarkLateRuns, CancellationCleanup, ProactiveTriggers, etc.) show enabled.
3) Observe service scheduling logs
- Look for logs like:
- “Registered perpetual service … key=…”
- “Scheduled perpetual task … key=… next run in …”
- You should see these on multiple pods at startup, but only one pod should then show actual execution logs repeatedly.
4) Observe execution logs for a singleton service
- Pick one service (e.g., Scheduler). In logs across pods/clusters:
- Only one pod should repeatedly emit messages like “scheduler: starting cycle”, “scheduler: completed cycle (X runs)”.
- Other pods should be mostly quiet for that service (aside from occasional scheduling/no-op messages).
5) Failover test
- Identify the pod that is actively executing the service (e.g., Scheduler).
- Kill that pod.
- Expectation:
- Within ~10s, its worker heartbeat disappears.
- Within the redelivery window (up to ~5 min), another pod begins executing that service (you’ll see the same “starting cycle” logs appear on a different pod).
- No overlapping cycles should occur during steady state; a small overlap during failover is possible right at takeover boundaries.
6) Watch Redis state (optional)
- Check the worker set in Redis:
- ZSET {docket_name}:workers should contain your active workers with recent timestamps.
- During failover, the previous worker’s entry ages out (~10s) and a new worker starts claiming tasks.
7) Look for signs of flapping
- Repeated “starting cycle” logs rapidly bouncing between pods without kills typically indicates network instability to Redis or clock skew.
- Action: stabilize network, ensure time sync, or reduce cross-region latency.
If you really need different timeouts
- Today, Prefect does not expose Docket’s heartbeat/redelivery as settings. Changing them requires:
- Editing Prefect’s server code to instantiate the Docket Worker with custom parameters, or
- Opening a feature request to expose these via PREFECT_SERVER_DOCKET_* settings.
- If you want help crafting a minimal patch to set a shorter redelivery timeout (e.g., 60–120s), I can provide a snippet and the exact file/lines to modify.
Given your multi-cluster setup and heavy load, I’d focus on:
- Keeping Redis close and stable
- Scaling consumer-group services (TaskRunRecorder, EventPersister) horizontally
- Verifying singleton services show one active executor at a time with the checklist aboveMarvin
04/14/2026, 5:59 AMChalama Reddy
04/14/2026, 6:06 AMMarvin
04/14/2026, 6:06 AMMarvin
04/14/2026, 6:06 AM# All API and background service pods in all clusters:
PREFECT_SERVER_DOCKET_URL=redis://<host>:6379/<db>
PREFECT_SERVER_DOCKET_NAME=prefect-server # same everywhere
If you need to separate concerns (e.g., run services in one cluster and only API/UI in another), keep the same Docket name and simply disable singleton services on the cluster that should not run them:
PREFECT_SERVER_SERVICES_SCHEDULER_ENABLED=false
PREFECT_SERVER_SERVICES_TRIGGERS_ENABLED=false
PREFECT_SERVER_SERVICES_LATE_RUNS_ENABLED=false
PREFECT_SERVER_SERVICES_CANCELLATION_CLEANUP_ENABLED=false
PREFECT_SERVER_SERVICES_DB_VACUUM_ENABLED=none
# (and any other singleton services you’ve enabled)
Summary
- Same Docket URL + same Docket name across all instances = safe, coordinated single active executor per singleton service.
- Different names against the same DB = split‑brain and the kinds of deadlocks/races you’ve observed.Chalama Reddy
04/14/2026, 6:14 AMRepossessor serviceMarvin
04/14/2026, 6:14 AMMarvin
04/14/2026, 6:16 AMChalama Reddy
04/14/2026, 6:33 AMMarvin
04/14/2026, 6:33 AMMarvin
04/14/2026, 6:34 AM# Use Redis as the messaging broker
PREFECT_MESSAGING_BROKER=prefect_redis.messaging
# Prefer a full URL; it takes precedence over host/port/db vars
PREFECT_REDIS_MESSAGING_URL=redis://<redis-host>:6379/0
# If you’re not using the URL, these are honored:
PREFECT_REDIS_MESSAGING_HOST=<redis-host>
PREFECT_REDIS_MESSAGING_PORT=6379
PREFECT_REDIS_MESSAGING_DB=0
# Docket (leader coordination for singleton services)
PREFECT_SERVER_DOCKET_URL=redis://<redis-host>:6379/1
PREFECT_SERVER_DOCKET_NAME=prefect-server # same across all pods/clusters
Notes and caveats
- URL precedence: If PREFECT_REDIS_MESSAGING_URL is set, it overrides PREFECT_REDIS_MESSAGING_HOST/PORT/DB. Use the URL form to avoid ambiguity.
- Separate DBs: Using /0 for messaging and /1 for Docket keeps keys and monitoring clean. It’s not required, but strongly recommended.
- Managed Redis cluster mode: Some providers only allow DB 0. In that case, set:
- PREFECT_REDIS_MESSAGING_URL=redis://<host>:6379/0
- PREFECT_SERVER_DOCKET_URL=redis://<host>:6379/0
This is safe because Prefect/Docket namespace their keys; just ensure the same PREFECT_SERVER_DOCKET_NAME across all pods so singleton services coordinate correctly.
- TLS/auth: Use rediss and embed credentials in the URL if needed:
PREFECT_REDIS_MESSAGING_URL=rediss://:<password>@<host>:6379/0
PREFECT_SERVER_DOCKET_URL=rediss://:<password>@<host>:6379/1
- Multiple clusters: All API/background-service pods in all clusters must use identical values for PREFECT_REDIS_MESSAGING_URL, PREFECT_SERVER_DOCKET_URL, and PREFECT_SERVER_DOCKET_NAME.
Validation
- On a pod in each cluster, confirm:
prefect config view | grep -E "PREFECT_MESSAGING_BROKER|PREFECT_REDIS_MESSAGING_URL|PREFECT_REDIS_MESSAGING_DB|PREFECT_SERVER_DOCKET_URL|PREFECT_SERVER_DOCKET_NAME"
- Watch logs on startup for successful connections to Redis (messaging and Docket) and ensure you see only one active executor for singleton services (e.g., Scheduler) across the fleet.
If you share your Redis provider (self-hosted vs ElastiCache/Azure/Redis Enterprise) I can tailor the exact URL syntax and any provider-specific gotchas.Chalama Reddy
04/14/2026, 6:52 AMRepossessor service should also run on API pod or only it run as background serviceMarvin
04/14/2026, 6:52 AMMarvin
04/14/2026, 6:53 AMprefect server services start) and disable services on API pods.
- Pros: isolates service load. Cons: extra component to manage.
Environment toggles
- Enable/disable:
- PREFECT_SERVER_SERVICES_REPOSSESSOR_ENABLED=true|false
- Scan interval:
- PREFECT_SERVER_SERVICES_REPOSSESSOR_LOOP_SECONDS=15 (example)
- Critical shared backends (same across all pods/clusters):
- PREFECT_SERVER_DOCKET_URL=redis://<host>:6379/1
- PREFECT_SERVER_DOCKET_NAME=prefect-server
- Messaging/DB as you’ve already configured
Examples
- API pods with repossessor enabled (co-located):
PREFECT_SERVER_SERVICES_REPOSSESSOR_ENABLED=true
- Dedicated services pod only:
- Services pod:
PREFECT_SERVER_SERVICES_REPOSSESSOR_ENABLED=true
- API pods:
PREFECT_SERVER_SERVICES_REPOSSESSOR_ENABLED=false
Verification
- Logs should show on exactly one pod at a time:
- “monitor_expired_leases loop started”
- “revoking expired lease …”
- If you kill the active pod, another pod should begin logging those messages within the next loop or two.
Bottom line: Run it wherever you run Prefect Server services. By default, that’s on API pods; if you prefer separation, run it in a services-only deployment and disable it on the API pods. In all cases, keep the same Redis-backed Docket URL + name so only one executor is active.Chalama Reddy
04/14/2026, 12:30 PMChalama Reddy
04/14/2026, 2:54 PMMarvin
04/14/2026, 2:55 PMChalama Reddy
04/14/2026, 2:55 PMChalama Reddy
04/14/2026, 2:58 PMMarvin
04/14/2026, 2:58 PMMarvin
04/14/2026, 2:58 PM.id fields that are Python UUID objects.
- If a template uses the Jinja tojson filter on a UUID, Python’s json module raises:
TypeError: object of type UUID is not JSON serializable
- You’ll see a warning like:
prefect.server.utilities.user_templates - unhandled exception rendering template. TypeError: object of type UUID is not JSON serializable
Where you typically hit this
- Webhook payload templates (CallWebhook action)
- Notification templates
- RunDeployment parameter templates
How to fix templates safely
- Prefer direct interpolation or convert to string before JSON encoding:
- Direct (Jinja auto-coerces to string):
{{ flow_run.id }}
- Explicit string conversion:
{{ flow_run.id | string }}
- If you’re building JSON yourself, only apply tojson to fields that are already JSON-serializable, and stringify UUIDs:
{
"flow_run_id": "{{ flow_run.id | string }}",
"automation_id": "{{ automation.id | string }}",
"name": {{ deployment.name | tojson }}
}
- Avoid:
- {{ flow_run.id | tojson }} # triggers the UUID serialization error
Tips
- If you need a pure-JSON payload, stringify all UUIDs in the template.
- For debugging, temporarily remove tojson from expressions to find which value contains a UUID.
- After rendering, Prefect may try to coerce a rendered scalar or object back to native JSON when appropriate, but the tojson step happens first — so keep UUIDs out of tojson.
If you can share the specific template you’re using, I can mark exactly which fields to wrap with | string and which can safely use | tojson.Marvin
04/14/2026, 3:03 PM{
"flow_run_id": "{{ flow_run.id }}",
"count": {{ count | tojson }}
}
- As an advanced workaround, register a custom tojson that handles UUIDs (default=str). If you control the server process and can extend filters:
from prefect.server.utilities.user_templates import register_user_template_filters
import json
def tojson_uuid_safe(obj):
return json.dumps(obj, default=str)
register_user_template_filters({"tojson": tojson_uuid_safe})
Note: this is process-local; make sure all API pods load the filter if you go this route.
Recommended next steps
- Update any notification/webhook/automation templates that reference IDs:
- Replace | tojson with plain string interpolation for UUIDs.
- Keep | tojson for numeric/dict data where needed.
- If you hit this in RunDeployment parameter templating, ensure parameter values that may be UUIDs are rendered as strings in the template.
If you can share a small snippet of the template that triggers the warning, I can suggest the minimal change to fix it while preserving your intended output.Chalama Reddy
04/14/2026, 3:10 PMMarvin
04/14/2026, 3:10 PMMarvin
04/14/2026, 3:16 PMPREFECT_SERVER_SERVICES_TASK_RUN_RECORDER_READ_BATCH_SIZE=50
PREFECT_SERVER_SERVICES_TASK_RUN_RECORDER_BATCH_SIZE=20
PREFECT_SERVER_SERVICES_TASK_RUN_RECORDER_FLUSH_INTERVAL=1
- Smaller DB batch_size reduces the number of rows locked at once (and the lock window), which usually lowers deadlock frequency. Keep read_batch_size moderate so you still drain the queue efficiently.
- Keep consumer-group uniqueness
- Ensure all recorder pods are in the same consumer group (this is the default) and that there is only one Redis broker (no shadow brokers). If any recorder points at a different broker/db, the same events can be processed by multiple pods and collide in Postgres.
Database-side visibility and hygiene
- Turn on deadlock diagnostics so we can see the exact relations involved:
ALTER SYSTEM SET deadlock_timeout = '2s';
ALTER SYSTEM SET log_lock_waits = on;
SELECT pg_reload_conf();
Then share the deadlock report from Postgres logs; it will show which tables/rows were in the cycle (typically task_runs and task_run_states).
- Check for FK/unique constraints that can increase lock scope
- List constraints:
SELECT conname, conrelid::regclass AS table, confrelid::regclass AS ref_table, contype
FROM pg_constraint
WHERE conrelid::regclass::text IN ('task_runs','task_run_states');
- If there’s a FK from task_runs.state_id → task_run_states.id, it can participate in lock inversions during concurrent upserts. If present, consider removing that FK and relying on application-level guarantees — it has been a source of deadlocks in high-throughput paths.
- Monitor lock contention:
SELECT bl.pid AS blocked_pid, ka.query AS blocking_query, now() - ka.query_start AS blocking_for,
kl.relation::regclass AS blocking_relation, a.query AS blocked_query
FROM pg_locks bl
JOIN pg_stat_activity a ON a.pid = bl.pid
JOIN pg_locks kl ON kl.locktype = bl.locktype AND kl.relation = bl.relation AND kl.granted
JOIN pg_stat_activity ka ON ka.pid = kl.pid
WHERE NOT bl.granted;
Throughput vs safety tuning (pick a lane)
- If your main pain is deadlocks:
- Lower batch_size (e.g., 10–20) and reduce the number of recorder replicas to 1–2. This shortens transactions and lowers cross-pod overlap.
- If your main pain is lag:
- Increase replicas but keep batch_size modest (20–50). Avoid very large batches; large multi-row upserts across many keys raise deadlock probability.
Operational tips
- Keep all recorder instances physically close (network-wise) to the same Postgres to minimize clock/latency skew.
- Ensure PgBouncer (if used) is in session pooling mode for the recorder connections and has enough pool size to avoid stalls under bursts.
- Verify all API/recorder pods point to the writer endpoint (not a reader or mixed R/W proxy).
Upgrade path
- There have been improvements to the recorder’s bulk write path and deadlock resilience in newer 3.x releases. If you can, test on the latest 3.x — several teams have seen fewer (or no) deadlocks after upgrading.Marvin
04/14/2026, 3:16 PMChalama Reddy
04/14/2026, 3:22 PMPREFECT_SERVER_SERVICES_TASK_RUN_RECORDER_READ_BATCH_SIZE=50
PREFECT_SERVER_SERVICES_TASK_RUN_RECORDER_BATCH_SIZE=50
PREFECT_SERVER_SERVICES_TASK_RUN_RECORDER_FLUSH_INTERVAL=1Marvin
04/14/2026, 3:22 PMMarvin
04/14/2026, 3:23 PMPREFECT_SERVER_SERVICES_TASK_RUN_RECORDER_READ_BATCH_SIZE=100
PREFECT_SERVER_SERVICES_TASK_RUN_RECORDER_BATCH_SIZE=20
PREFECT_SERVER_SERVICES_TASK_RUN_RECORDER_FLUSH_INTERVAL=1
- Stagger flush timings across clusters
- Avoid synchronized commits by slightly offsetting intervals:
- Cluster A: FLUSH_INTERVAL=1.00
- Cluster B: FLUSH_INTERVAL=1.15 (or 1.25)
- Confirm single consumer group and single broker
- All recorder pods must use the same Redis broker and the same consumer group (default for the recorder). Any stray recorder pointing at a different broker/DB will reprocess the same events and increase collisions.
- As a baseline test, run a single global recorder
- Temporarily disable the recorder in one cluster to confirm deadlocks stop. If they do, the issue is row-level overlap between recorders — then keep batch_size smaller and add back the second recorder.
Postgres visibility and tuning
- Enable deadlock diagnostics (helps pinpoint exact tables/queries):
ALTER SYSTEM SET deadlock_timeout = '2s';
ALTER SYSTEM SET log_lock_waits = on;
SELECT pg_reload_conf();
- Capture the deadlock report from Postgres logs and share it; it will name the relations (typically task_runs and task_run_states) and lock order.
- Sanity checks:
- Ensure the recorder connects to the writer/primary endpoint (not a reader or mixed proxy).
- If you use PgBouncer, prefer session pooling for recorder connections and raise pool size so commits don’t queue behind long-lived sessions.
If deadlocks persist
- Lower DB batch_size further (e.g., 10–15) and/or reduce the number of recorder pods (1 global recorder is the simplest way to eliminate cross-pod overlap).
- Keep READ_BATCH_SIZE high (50–100) so you still read efficiently from Redis, but write to Postgres in smaller chunks.
Helpful SQL to inspect constraints (to see what’s involved in the deadlock)
-- List relevant constraints
SELECT conname, conrelid::regclass AS table, confrelid::regclass AS ref_table, contype
FROM pg_constraint
WHERE conrelid::regclass::text IN ('task_runs','task_run_states');
-- Observe blocking/blocked locks when it happens
SELECT bl.pid AS blocked_pid, ka.query AS blocking_query, now() - ka.query_start AS blocking_for,
kl.relation::regclass AS blocking_relation, a.query AS blocked_query
FROM pg_locks bl
JOIN pg_stat_activity a ON a.pid = bl.pid
JOIN pg_locks kl ON kl.locktype = bl.locktype AND kl.relation = bl.relation AND kl.granted
JOIN pg_stat_activity ka ON ka.pid = kl.pid
WHERE NOT bl.granted;
Next steps
- Try the (100, 20, 1) settings and add a 10–20% flush interval offset between clusters.
- If you still see DeadlockDetectedError, please share one deadlock report from the Postgres logs plus your recorder replica counts. I can then give you a precise plan (e.g., batch sizing, replica counts, or consolidating to a single global recorder) to get you stable.Chalama Reddy
04/14/2026, 3:26 PMMarvin
04/14/2026, 3:26 PMMarvin
04/14/2026, 3:27 PMredis-cli -u "$PREFECT_REDIS_MESSAGING_URL" ping
2) Find the stream key(s)
- If you know the key, skip to step 3. Otherwise, scan for streams in the DB and inspect them:
# List all stream keys (Redis >= 6)
redis-cli -u "$PREFECT_REDIS_MESSAGING_URL" --raw SCAN 0 TYPE stream
# If TYPE is not available, fallback:
redis-cli -u "$PREFECT_REDIS_MESSAGING_URL" --raw SCAN 0 MATCH *events*
# Then for each found key:
redis-cli -u "$PREFECT_REDIS_MESSAGING_URL" TYPE <key>
3) List consumer groups on each candidate stream
- Look for a group named task-run-recorder
redis-cli -u "$PREFECT_REDIS_MESSAGING_URL" XINFO GROUPS <stream_key>
4) Inspect the consumers in the group (one per recorder pod)
redis-cli -u "$PREFECT_REDIS_MESSAGING_URL" XINFO CONSUMERS <stream_key> task-run-recorder
- You should see one consumer entry per recorder pod (names are unique per pod). Check idle time and pending counts.
5) Check pending and lag for the group
# Pending messages summary for the group
redis-cli -u "$PREFECT_REDIS_MESSAGING_URL" XPENDING <stream_key> task-run-recorder
# (Redis 7+) Group info may include 'lag' in XINFO GROUPS output
Tips
- Make sure you connect to the same Redis DB index you configured in PREFECT_REDIS_MESSAGING_URL (e.g., redis://host:6379/0). If you separated Docket to a different DB (e.g., /1), use the messaging DB here.
- Enable DEBUG logs on API pods to see the recorder’s startup lines; they typically include the topic (“events”), the group (“task-run-recorder”), and the consumer name it registered with.
- If you don’t see task-run-recorder in any stream’s groups, double‑check:
- PREFECT_MESSAGING_BROKER=prefect_redis.messaging
- PREFECT_REDIS_MESSAGING_URL points to the right Redis instance/DB
- The recorder service is enabled and running: PREFECT_SERVER_SERVICES_TASK_RUN_RECORDER_ENABLED=true
If you share the output of:
- SCAN for stream keys
- XINFO GROUPS for the candidate stream(s)
…I can help you pinpoint the exact stream key Prefect is using and confirm all recorder pods are in the same group.Chalama Reddy
04/14/2026, 3:32 PMMarvin
04/14/2026, 3:32 PMMarvin
04/14/2026, 3:33 PMredis-cli -u "<redis://host:6379/0>" ping
2) Find the stream key(s) used for events
- If your Redis supports TYPE with SCAN:
redis-cli -u "<redis://host:6379/0>" --raw SCAN 0 TYPE stream
- If TYPE is not available, scan and filter by name, then confirm TYPE:
# Find likely event streams (names vary by setup)
redis-cli -u "<redis://host:6379/0>" --raw SCAN 0 MATCH "*events*"
# For each candidate key:
redis-cli -u "<redis://host:6379/0>" TYPE <stream_key>
3) List consumer groups on each candidate stream
- You’re looking for a group named task-run-recorder
redis-cli -u "<redis://host:6379/0>" XINFO GROUPS <stream_key>
4) Inspect the recorder consumers in that group
- Expect one consumer entry per recorder pod (each pod has a unique consumer name)
redis-cli -u "<redis://host:6379/0>" XINFO CONSUMERS <stream_key> task-run-recorder
- Check:
- name: unique per pod
- pending: how many messages are assigned to that consumer
- idle: how long it’s been since the consumer last acknowledged a message
5) Check pending and lag
# Pending overview for the group
redis-cli -u "<redis://host:6379/0>" XPENDING <stream_key> task-run-recorder
# Stream length (rough volume)
redis-cli -u "<redis://host:6379/0>" XLEN <stream_key>
Optional: one-liner to scan all streams and print any groups named task-run-recorder
for k in $(redis-cli -u "<redis://host:6379/0>" --raw SCAN 0 TYPE stream | awk 'NR>2'); do
if redis-cli -u "<redis://host:6379/0>" XINFO GROUPS "$k" 2>/dev/null | grep -q "task-run-recorder"; then
echo "Stream: $k"
redis-cli -u "<redis://host:6379/0>" XINFO GROUPS "$k"
redis-cli -u "<redis://host:6379/0>" XINFO CONSUMERS "$k" task-run-recorder
fi
done
If you don’t see task-run-recorder anywhere:
- Confirm on the pods:
- prefect config view | grep -E "PREFECT_MESSAGING_BROKER|PREFECT_REDIS_MESSAGING_URL"
- Ensure PREFECT_SERVER_SERVICES_TASK_RUN_RECORDER_ENABLED=true
- Check API logs (set PREFECT_LOG_LEVEL=DEBUG) for lines indicating the recorder started; it typically logs the group (“task-run-recorder”), stream key, and its unique consumer name.
- Make sure you’re connected to DB 0 (PREFECT_REDIS_MESSAGING_URL=redis://host:6379/0), not DB 1 (that’s only for Docket).
If you share the XINFO output for the stream key you find, I can help verify that both clusters’ recorder pods are in the same group and reading as expected.Chalama Reddy
04/15/2026, 8:58 AMMarvin
04/15/2026, 8:58 AMMarvin
04/15/2026, 8:59 AMredis-cli -u "<redis://host:6379/0>" ping
2) Discover the stream key(s)
- Prefect uses Redis Streams for messaging; the exact key name can vary. Scan for streams, then narrow down by name:
redis-cli -u "<redis://host:6379/0>" --raw SCAN 0 TYPE stream
# If TYPE is not supported on your Redis:
redis-cli -u "<redis://host:6379/0>" --raw SCAN 0 MATCH "*events*"
redis-cli -u "<redis://host:6379/0>" --raw SCAN 0 MATCH "*prefect*"
# For each candidate key, confirm it is a stream:
redis-cli -u "<redis://host:6379/0>" TYPE <stream_key>
3) Inspect stream info and recent entries
# Stream metadata (length, last-generated-id, groups)
redis-cli -u "<redis://host:6379/0>" XINFO STREAM <stream_key>
# Peek at recent messages (adjust COUNT as needed)
redis-cli -u "<redis://host:6379/0>" XRANGE <stream_key> - + COUNT 5
- You’ll see entries like:
- ID: 1712861234567-0
- Fields: key/value pairs. One of the fields typically contains the event payload (JSON). Use the above XRANGE output to identify the exact field name (often something like data/payload/message).
4) “Tail -f” new events without consuming them
- This listens for new messages from “now” and does not affect consumer groups or pending lists:
redis-cli -u "<redis://host:6379/0>" XREAD BLOCK 0 STREAMS <stream_key> $
- Leave this running to see events live as the API publishes them.
Optional: check the Task Run Recorder consumer group
- Group name is typically task-run-recorder. To confirm membership and lag:
redis-cli -u "<redis://host:6379/0>" XINFO GROUPS <stream_key>
redis-cli -u "<redis://host:6379/0>" XINFO CONSUMERS <stream_key> task-run-recorder
redis-cli -u "<redis://host:6379/0>" XPENDING <stream_key> task-run-recorder
Python snippet to pretty-print payloads
- If you want structured logging and JSON parsing:
import json, time
import redis
STREAM = "<stream_key>"
r = redis.Redis.from_url("<redis://host:6379/0>")
last_id = "$" # start from new messages; use "0-0" to read backlog
while True:
resp = r.xread({STREAM: last_id}, block=10000, count=100)
if not resp:
continue
for key, messages in resp:
for mid, fields in messages:
# Inspect which field holds the JSON payload
payload = fields.get(b"data") or fields.get(b"payload") or fields.get(b"message")
if payload:
try:
evt = json.loads(payload)
print(f"{mid} event={evt.get('event')} resource={evt.get('resource')}")
except Exception:
print(f"{mid} raw payload: {payload!r}")
else:
print(f"{mid} fields: {fields}")
last_id = mid
Important cautions
- Do not use XREADGROUP in production just to “watch” messages — groups/consumers will claim messages and can impact your services. Use XREAD with a plain stream instead.
- MONITOR will show every Redis command but is extremely noisy and can impact performance; only use it briefly if you must:
redis-cli -u "<redis://host:6379/0>" MONITOR | grep XADD
If you paste the XRANGE output for your candidate stream, I can help you identify the exact field containing the event JSON and tailor a jq or Python formatter for your setup.Chalama Reddy
04/15/2026, 9:04 AMMarvin
04/15/2026, 9:04 AMMarvin
04/15/2026, 9:05 AMdocker run --rm -it redis:7-alpine \
redis-cli -u "redis://:<PASSWORD>@host:6379/0" ping
Find the stream key(s)
- Try TYPE-filtered scan (Redis 7+):
docker run --rm -it redis:7-alpine \
redis-cli -u "redis://:<PASSWORD>@host:6379/0" --raw SCAN 0 TYPE stream
- Fallback (any Redis), then confirm each key’s type:
docker run --rm -it redis:7-alpine \
redis-cli -u "redis://:<PASSWORD>@host:6379/0" --raw SCAN 0 MATCH "*events*"
docker run --rm -it redis:7-alpine \
redis-cli -u "redis://:<PASSWORD>@host:6379/0" TYPE <stream_key>
Inspect the stream and groups
docker run --rm -it redis:7-alpine \
redis-cli -u "redis://:<PASSWORD>@host:6379/0" XINFO STREAM <stream_key>
docker run --rm -it redis:7-alpine \
redis-cli -u "redis://:<PASSWORD>@host:6379/0" XINFO GROUPS <stream_key>
- You should see a group named task-run-recorder (and possibly others).
Tail new events (non-destructive)
docker run --rm -it redis:7-alpine \
redis-cli -u "redis://:<PASSWORD>@host:6379/0" XREAD BLOCK 0 STREAMS <stream_key> $
- Leave this running to see new events as they’re published. One of the fields (often data or payload) will contain the JSON event.
Peek at recent messages
docker run --rm -it redis:7-alpine \
redis-cli -u "redis://:<PASSWORD>@host:6379/0" XRANGE <stream_key> - + COUNT 5
If you prefer exec’ing into a running container
- Make sure you exec into a container that actually has redis-cli (typically the Redis container, not the Prefect API container). For example:
docker exec -it <redis-container-name> redis-cli -a <PASSWORD> -n 0 XINFO STREAM <stream_key>
Optional: verify XADD operations (noisy, use briefly)
docker run --rm -it redis:7-alpine \
redis-cli -u "redis://:<PASSWORD>@host:6379/0" MONITOR | grep XADD
Common gotchas
- PSUBSCRIBE subscribes to Pub/Sub channels; it does not show Stream entries.
- Ensure you connect to DB 0 (messaging) — your Docket is on DB 1.
- If you don’t see any groups with XINFO GROUPS, double-check you found the correct stream key or that the recorder is running/enabled.
If you paste the XRANGE output for the stream you find, I can help identify which field contains the JSON and suggest a jq/grep to pretty-print it.Chalama Reddy
04/15/2026, 9:09 AMMarvin
04/15/2026, 9:09 AMMarvin
04/15/2026, 9:10 AMdocker exec -it <redis-container> redis-cli -a <PASSWORD> -n 0 XINFO STREAM events
- See consumer groups on the events stream
docker exec -it <redis-container> redis-cli -a <PASSWORD> -n 0 XINFO GROUPS events
- Inspect consumers in a specific group (e.g., task-run-recorder)
docker exec -it <redis-container> redis-cli -a <PASSWORD> -n 0 XINFO CONSUMERS events task-run-recorder
- Tail new events non-destructively (like tail -f)
docker exec -it <redis-container> redis-cli -a <PASSWORD> -n 0 XREAD BLOCK 0 STREAMS events $
- Peek at recent messages
docker exec -it <redis-container> redis-cli -a <PASSWORD> -n 0 XRANGE events - + COUNT 5
What you’ll see in messages
- Each entry has fields; one typically contains the JSON event payload (often under “data” and a small metadata blob under “attributes”). Use XRANGE to see exact field names for your build, then parse accordingly.
If XINFO STREAM events errors
- Make sure you’re connected to DB 0 (messaging), not DB 1 (Docket).
- If the stream doesn’t exist yet, trigger a small flow/task to generate events, then re-run the command.
- To discover streams:
docker exec -it <redis-container> redis-cli -a <PASSWORD> -n 0 --raw SCAN 0 TYPE streamChalama Reddy
04/15/2026, 9:18 AMMarvin
04/15/2026, 9:18 AMMarvin
04/15/2026, 9:19 AMevents (DB 0 in your setup). You can non-destructively watch them with XREAD:
- Tail new events (like tail -f):
docker exec -it <redis-container> redis-cli -a <PASSWORD> -n 0 XREAD BLOCK 0 STREAMS events $
- Peek at recent messages (to learn field names like data/attributes):
docker exec -it <redis-container> redis-cli -a <PASSWORD> -n 0 XRANGE events - + COUNT 5
- See consumer groups (to verify services consuming events):
docker exec -it <redis-container> redis-cli -a <PASSWORD> -n 0 XINFO GROUPS events
Note: Redis Streams are not Pub/Sub; PSUBSCRIBE won’t show these messages.
B) From Prefect (enable DEBUG logs for the messaging pipeline)
If you want the Prefect API to log the messaging activity to its logs, turn on DEBUG for the messaging loggers:
- Minimal:
export PREFECT_LOG_LEVEL=DEBUG
export PREFECT_LOGGING_EXTRA_LOGGERS="prefect.server.events.messaging,prefect_redis.messaging,prefect.server.events.services.event_persister,prefect.server.services.task_run_recorder"
- Or with a full logging settings override (JSON) to force DEBUG for those specific loggers:
export PREFECT_LOGGING_SETTINGS='{
"loggers": {
"prefect.server.events.messaging": {"level": "DEBUG"},
"prefect_redis.messaging": {"level": "DEBUG"},
"prefect.server.events.services.event_persister": {"level": "DEBUG"},
"prefect.server.services.task_run_recorder": {"level": "DEBUG"}
}
}'
Then restart your API pods. You’ll see detailed logs about publishing/consuming batches, flushes, and any errors.
If you literally want messages in the Redis server logs
Redis doesn’t log stream payloads to its server log. You can approximate this with MONITOR (very noisy; not recommended in prod) to see XADD operations:
docker exec -it <redis-container> redis-cli -a <PASSWORD> -n 0 MONITOR | grep XADD
But for actual event contents, use XRANGE/XREAD as above.
If you want help formatting the event JSON from XRANGE/XREAD, paste a sample entry and I’ll show you a jq/awk one-liner to pretty-print just the event type and resource.Chalama Reddy
04/15/2026, 9:35 AMMarvin
04/15/2026, 9:35 AMMarvin
04/15/2026, 9:36 AMdocker exec -it <redis-container> sh -lc '
redis-cli -a "<PASSWORD>" -n 0 --raw XRANGE events - + COUNT 5 |
awk '"'"'
/^[0-9-]+$/ {print "---"; print "id: "$0; next}
$0=="data" {getline v; print "data: " v; next}
$0=="attributes" {getline v; print "attributes: " v; next}
'"'"'
'
Tail live events (non-destructive “follow”)
docker exec -it <redis-container> sh -lc '
redis-cli -a "<PASSWORD>" -n 0 --raw XREAD BLOCK 0 STREAMS events $ |
awk '"'"'
/^[0-9-]+$/ {print "---"; print "id: "$0; next}
$0=="data" {getline v; print "data: " v; next}
$0=="attributes" {getline v; print "attributes: " v; next}
'"'"'
'
If your redis-cli supports JSON output (Redis 7+), you can use jq to extract fields precisely:
docker exec -it <redis-container> sh -lc '
redis-cli -a "<PASSWORD>" -n 0 --json XRANGE events - + COUNT 5 |
jq -r ".[] | {id: .[0], obj: (reduce range(0; (.[1]|length); 2) as \$i ({}; . + { (.[1][\$i]): (.[1][\$i+1]) }))} | {id, data: .obj.data, attributes: .obj.attributes}"
'
Notes
- These commands assume your event entries store the JSON payload under data and a small metadata blob under attributes. If your fields differ, first run:
docker exec -it <redis-container> redis-cli -a <PASSWORD> -n 0 --raw XRANGE events - + COUNT 1
and adjust the awk/jq keys accordingly.
- Use DB 0 for messaging (your Docket is on DB 1).
- XREAD (without GROUP) is safe for “tapping” the stream; it won’t claim or acknowledge messages used by your background services.Chalama Reddy
04/15/2026, 10:03 AMMarvin
04/15/2026, 10:03 AMMarvin
04/15/2026, 10:03 AMdocker exec -it <redis-container> sh -lc '
redis-cli -a "<PASSWORD>" -n 0 --raw XRANGE events - + COUNT 10 |
awk '"'"'
/^[0-9-]+$/ {id=$0; next} # capture message id
$0=="data" {getline v; print v} # print only the data value
'"'"'
'
Tail live data (non-destructive follow)
docker exec -it <redis-container> sh -lc '
redis-cli -a "<PASSWORD>" -n 0 --raw XREAD BLOCK 0 STREAMS events $ |
awk '"'"'
/^[0-9-]+$/ {id=$0; next}
$0=="data" {getline v; print v}
'"'"'
'
If your redis-cli supports JSON output (Redis 7+), you can parse just the data field with jq:
Last N entries (JSON-aware)
docker exec -it <redis-container> sh -lc '
redis-cli -a "<PASSWORD>" -n 0 --json XRANGE events - + COUNT 10 |
jq -r ".[] | {id: .[0], fields: (reduce range(0; (.[1]|length); 2) as \$i ({}; . + { (.[1][\$i]): (.[1][\$i+1]) }))} | .fields.data"
'
Notes
- These assume the event JSON payload is stored under the field name data. If your entries use a different field (e.g., payload or message), run a quick peek to confirm and adjust:
docker exec -it <redis-container> redis-cli -a <PASSWORD> -n 0 --raw XRANGE events - + COUNT 1
- Use DB 0 (messaging) for events; your Docket is on DB 1.
- XREAD (without GROUP) is safe for observing; it does not claim/ack messages from your background services.Chalama Reddy
04/15/2026, 11:08 AMMarvin
04/15/2026, 11:08 AMMarvin
04/15/2026, 11:08 AMPREFECT_LOG_LEVEL=DEBUG
PREFECT_LOGGING_EXTRA_LOGGERS="prefect.server.events.messaging,prefect_redis.messaging,prefect.server.events.services.event_persister,prefect.server.services.task_run_recorder,prefect.server.services.scheduler,prefect.server.services.late_runs,prefect.server.services.cancellation_cleanup,prefect.server.services.repossessor,prefect.server.api.background_workers,docket"
Or use a JSON override to force DEBUG per-logger
PREFECT_LOGGING_SETTINGS='{
"loggers": {
"prefect.server.events.messaging": {"level": "DEBUG"},
"prefect_redis.messaging": {"level": "DEBUG"},
"prefect.server.events.services.event_persister": {"level": "DEBUG"},
"prefect.server.services.task_run_recorder": {"level": "DEBUG"},
"prefect.server.services.scheduler": {"level": "DEBUG"},
"prefect.server.services.late_runs": {"level": "DEBUG"},
"prefect.server.services.cancellation_cleanup": {"level": "DEBUG"},
"prefect.server.services.repossessor": {"level": "DEBUG"},
"prefect.server.api.background_workers": {"level": "DEBUG"},
"docket": {"level": "DEBUG"}
}
}'
What you’ll see
- Scheduler/“scheduled”:
- Acquisition via Docket (look for “acquired lease” / “perpetual service”)
- “scheduler cycle start/complete”, “scheduled N flow runs”
- Event Persister:
- “read batch size”, “flushing N events”, “persisted N events”, queue capacity warnings
- Task Run Recorder:
- “read N messages”, “flushed N task runs / M states”, and any retry/backoff (e.g., on deadlocks)
- Messaging:
- Publish/consume details from prefect.server.events.messaging and prefect_redis.messaging
Grab logs from Kubernetes
# Tail all API pods and filter for the services you care about
kubectl logs deploy/<api-deployment> -c <api-container> -f \
| egrep -i "event persister|task run recorder|scheduler|late runs|cancellation cleanup|repossessor|perpetual|acquired lease|flushing|persisted"
B) Optional: Redis-side visibility (DB 0 for messaging)
- Show consumer groups for events stream
docker exec -it <redis-container> redis-cli -a <PASSWORD> -n 0 XINFO GROUPS events
docker exec -it <redis-container> redis-cli -a <PASSWORD> -n 0 XINFO CONSUMERS events task-run-recorder
docker exec -it <redis-container> redis-cli -a <PASSWORD> -n 0 XINFO CONSUMERS events event-persister
- Tail the event payloads (non-destructive)
docker exec -it <redis-container> sh -lc '
redis-cli -a "<PASSWORD>" -n 0 --raw XREAD BLOCK 0 STREAMS events $ \
| awk '"'"'/^[0-9-]+$/{next} $0=="data"{getline v; print v}'"'"'
'
- Very noisy (use sparingly): see XADD/XREADGROUP/XAUTOCLAIM in real time
docker exec -it <redis-container> redis-cli -a <PASSWORD> -n 0 MONITOR | egrep "XADD|XREADGROUP|XAUTOCLAIM|XACK|XTRIM"
C) Tips
- Make sure you’re logging from the pods that actually run the services (API/background services). Workers do not run these server services.
- If you split API vs “services-only” deployments, enable the loggers on the services deployment.
- Verify services are enabled:
prefect server services ls
- For “scheduled” specifically, the scheduler, recent-deployments scheduler, late-runs, cancellation cleanup, and repossessor will log via Docket/Perpetual. The “docket” logger will show lease acquisition/failover details.
If you paste a few lines of your current logs, I can help you tune the filters and confirm you’re seeing the expected messages for each service.Chalama Reddy
04/15/2026, 11:16 AMMarvin
04/15/2026, 11:16 AMMarvin
04/15/2026, 11:16 AMdocker exec -it <redis-container> sh -lc '
redis-cli -a "<PASSWORD>" -n 0 --raw XRANGE events - + COUNT 10 |
awk '"'"'
/^[0-9-]+$/ {next} # skip message ids
$0=="data" {getline v; print v} # print only the data field value (JSON)
'"'"'
'
Tail live events (non-destructive “follow”)
docker exec -it <redis-container> sh -lc '
redis-cli -a "<PASSWORD>" -n 0 --raw XREAD BLOCK 0 STREAMS events $ |
awk '"'"'
/^[0-9-]+$/ {next}
$0=="data" {getline v; print v}
'"'"'
'
Filter to only Task Run Recorder–relevant events (prefect.task-run.*)
- Add jq to the pipelines above:
... | jq -r 'select(.event | startswith("prefect.task-run"))'
Filter to “scheduled” events (e.g., prefect.flow-run.scheduled)
... | jq -r 'select(.event == "prefect.flow-run.scheduled")'
If your redis-cli supports JSON output (Redis 7+), you can extract fields more cleanly:
docker exec -it <redis-container> sh -lc '
redis-cli -a "<PASSWORD>" -n 0 --json XRANGE events - + COUNT 10 |
jq -r ".[] | {id: .[0], fields: (reduce range(0; (.[1]|length); 2) as \$i ({}; . + { (.[1][\$i]): (.[1][\$i+1]) }))} | .fields.data"
'
Notes
- Use DB 0 for messaging (your Docket is on DB 1).
- XREAD without GROUP is safe for observing; it won’t claim/ack messages from your services.
- XINFO CONSUMERS is still useful to confirm group health:
- Task Run Recorder group: task-run-recorder
- Event Persister group: event-persister
docker exec -it <redis-container> redis-cli -a <PASSWORD> -n 0 XINFO CONSUMERS events task-run-recorder
docker exec -it <redis-container> redis-cli -a <PASSWORD> -n 0 XINFO CONSUMERS events event-persister
- If you need to see Redis commands (e.g., XADD/XREADGROUP) briefly:
docker exec -it <redis-container> redis-cli -a <PASSWORD> -n 0 MONITOR | egrep "XADD|XREADGROUP|XAUTOCLAIM|XACK"
Caution: MONITOR is very noisy; use sparingly.
If you paste one XRANGE entry (two lines for “data” and its JSON), I can help craft a jq to print exactly the fields you care about (event type, flow/task run ids, etc.).Chalama Reddy
04/15/2026, 11:22 AMMarvin
04/15/2026, 11:22 AMMarvin
04/15/2026, 11:22 AMdocker exec -it <redis-container> redis-cli -a <PASSWORD> -n 0 --raw XRANGE events - + COUNT 1
If you see a field called data with a JSON value, use the commands below.
Tail Task Run Recorder events (prefect.task-run.*)
- With jq (preferred):
docker exec -it <redis-container> sh -lc '
redis-cli -a "<PASSWORD>" -n 0 --raw XREAD BLOCK 0 STREAMS events $ \
| awk '"'"'/^[0-9-]+$/ {next} $0=="data" {getline v; print v}'"'"' \
| jq -r '"'"'select(.event | startswith("prefect.task-run"))'"'"'
'
- Without jq:
docker exec -it <redis-container> sh -lc '
redis-cli -a "<PASSWORD>" -n 0 --raw XREAD BLOCK 0 STREAMS events $ \
| awk '"'"'/^[0-9-]+$/ {next} $0=="data" {getline v; print v}'"'"' \
| grep '"'"'"event":"prefect.task-run"'"'"'
'
Tail Scheduler “scheduled” events (prefect.flow-run.scheduled)
- With jq:
docker exec -it <redis-container> sh -lc '
redis-cli -a "<PASSWORD>" -n 0 --raw XREAD BLOCK 0 STREAMS events $ \
| awk '"'"'/^[0-9-]+$/ {next} $0=="data" {getline v; print v}'"'"' \
| jq -r '"'"'select(.event == "prefect.flow-run.scheduled")'"'"'
'
- Without jq:
docker exec -it <redis-container> sh -lc '
redis-cli -a "<PASSWORD>" -n 0 --raw XREAD BLOCK 0 STREAMS events $ \
| awk '"'"'/^[0-9-]+$/ {next} $0=="data" {getline v; print v}'"'"' \
| grep '"'"'"event":"prefect.flow-run.scheduled"'"'"'
'
Tail all events (what Event Persister will persist)
- Raw payloads:
docker exec -it <redis-container> sh -lc '
redis-cli -a "<PASSWORD>" -n 0 --raw XREAD BLOCK 0 STREAMS events $ \
| awk '"'"'/^[0-9-]+$/ {next} $0=="data" {getline v; print v}'"'"'
'
- Or pretty-print with jq:
docker exec -it <redis-container> sh -lc '
redis-cli -a "<PASSWORD>" -n 0 --raw XREAD BLOCK 0 STREAMS events $ \
| awk '"'"'/^[0-9-]+$/ {next} $0=="data" {getline v; print v}'"'"' \
| jq -C .
'
Grab recent entries instead of “tail -f”
- Last 50 Task Run Recorder events:
docker exec -it <redis-container> sh -lc '
redis-cli -a "<PASSWORD>" -n 0 --raw XRANGE events - + COUNT 200 \
| awk '"'"'/^[0-9-]+$/ {next} $0=="data" {getline v; print v}'"'"' \
| jq -r '"'"'select(.event | startswith("prefect.task-run")) | .'"'"'
'
Notes
- Use DB 0 (messaging). Your Docket is on DB 1.
- If your stream stores the JSON under a different field (e.g., payload), replace data in the awk with that field name.
- XREAD (without GROUP) is non-destructive; it won’t interfere with your consumer groups.Chalama Reddy
04/15/2026, 12:17 PMMarvin
04/15/2026, 12:17 PMMarvin
04/15/2026, 12:18 PMPREFECT_SERVER_SERVICES_TASK_RUN_RECORDER_READ_BATCH_SIZE=100
PREFECT_SERVER_SERVICES_TASK_RUN_RECORDER_BATCH_SIZE=20
PREFECT_SERVER_SERVICES_TASK_RUN_RECORDER_FLUSH_INTERVAL=1
- Stagger flush intervals across clusters slightly (e.g., 1.00s vs 1.15s) to avoid synchronized commits.
- As a baseline test, run a single global Task Run Recorder (disable in one cluster) to confirm deadlocks stop; then re-introduce the second recorder with smaller DB batch_size.
- DB observability:
- Enable diagnostics to capture a deadlock graph:
ALTER SYSTEM SET deadlock_timeout = '2s';
ALTER SYSTEM SET log_lock_waits = on;
SELECT pg_reload_conf();
- Share one deadlock report; it will name the tables/queries involved and confirm it’s the recorder/persister path.
- Infra checks:
- Ensure all recorder/persister pods point to the Postgres writer (not a reader or mixed proxy).
- If using PgBouncer, prefer session pooling for these services and raise pool size to avoid commit stalls.
Bottom line
- Docket on DB 1 across clusters is fine and won’t cause Postgres deadlocks.
- Deadlocks arise from concurrent DB writes by multi-instance consumer services; Prefect will retry unacked messages, but you should tune batch sizes/flush timing (or temporarily reduce to one recorder) to eliminate the contention.Chalama Reddy
04/15/2026, 12:48 PMMarvin
04/15/2026, 12:48 PMMarvin
04/15/2026, 12:49 PM…/task_runs/<id>/set_state and the API writes the state synchronously. Events are published in parallel, so you can momentarily see the event before the DB commit lands — but the DB should update within seconds unless you’re reading from a replica or the API isn’t getting the set_state calls.
- Client-orchestrated tasks: Task Run Recorder consumes prefect.task-run.* events and writes task_runs/task_run_states in bulk. In this path, “Background Services received event” just means it was delivered; it’s still subject to batching, deadlocks, and retries before the DB reflects the new state.
Quick triage: which path is yours?
- Check API logs around the time of completion for set_state:
- Look for POST /api/task_runs/<id>/set_state and a 2xx response. If present, you’re server-orchestrated and the DB should reflect immediately. If missing, you’re relying on Task Run Recorder.
- If you’re using client orchestration or can’t confirm set_state calls, proceed with the recorder checks below.
If using Task Run Recorder, verify and tune
- Check if the event was delivered but is still pending (not acked):
# On the messaging DB (0)
redis-cli -u "<redis://host:6379/0>" XPENDING events task-run-recorder
redis-cli -u "<redis://host:6379/0>" XINFO CONSUMERS events task-run-recorder
High pending counts or long idle times mean the recorder hasn’t flushed/acked yet.
- Look for recorder flush/retry logs:
- Enable:
PREFECT_LOG_LEVEL=DEBUG
PREFECT_LOGGING_EXTRA_LOGGERS="prefect.server.services.task_run_recorder,prefect.server.events.messaging,prefect_redis.messaging"
- You should see “read N messages”, “flushed N task runs / M states”, or retries on errors (e.g., DeadlockDetectedError).
- Reduce Postgres lock overlap (common cause for delays with multiple recorder pods):
- Keep broker reads high, write smaller DB batches, and stagger flush intervals across clusters:
PREFECT_SERVER_SERVICES_TASK_RUN_RECORDER_READ_BATCH_SIZE=100
PREFECT_SERVER_SERVICES_TASK_RUN_RECORDER_BATCH_SIZE=20
PREFECT_SERVER_SERVICES_TASK_RUN_RECORDER_FLUSH_INTERVAL=1 # cluster A
# cluster B (stagger)
PREFECT_SERVER_SERVICES_TASK_RUN_RECORDER_FLUSH_INTERVAL=1.15
- Baseline test: temporarily run a single global Task Run Recorder (disable in one cluster). If the gap disappears, the issue is cross-pod overlap/deadlocks; keep batch_size modest (10–20) and add the second recorder back.
Database-side checks
- Verify the state row exists in task_run_states and whether task_runs has the pointer updated:
-- Replace with your task run id
SELECT id, state_type, name, timestamp
FROM task_run_states
WHERE task_run_id = '<TASK_RUN_ID>'
ORDER BY timestamp DESC
LIMIT 5;
SELECT state_id, state_type, state_timestamp
FROM task_runs
WHERE id = '<TASK_RUN_ID>';
If there’s no new state row, the recorder hasn’t written it yet (backlog/retry). If a Completed state exists but task_runs didn’t advance, share the deadlock logs; in practice, both operations occur in the same transaction, so you’ll usually see neither when a deadlock rolls back.
- Turn on Postgres deadlock diagnostics to capture one cycle:
ALTER SYSTEM SET deadlock_timeout = '2s';
ALTER SYSTEM SET log_lock_waits = on;
SELECT pg_reload_conf();
Then share one deadlock report so we can confirm it’s the recorder upsert and tune batch sizes further.
Also rule out these environment pitfallsMarvin
04/15/2026, 12:49 PMredis-cli -u "<redis://host:6379/0>" XINFO GROUPS events
- API vs services segregation: if you split “services-only” pods from API pods, make sure recorder is enabled and logging on the services pod and not accidentally disabled.
Bottom line
- A tiny window where an event is visible before the DB state commits is normal. Longer gaps indicate the recorder hasn’t flushed yet or is retrying after a deadlock.
- Keep recorder read_batch_size high, DB batch_size modest (10–20), stagger flush intervals across clusters, and confirm all writers hit the same Postgres writer. Enable DEBUG logs and Postgres deadlock diagnostics to verify the path. If you can share a deadlock snippet or a few lines of recorder DEBUG logs around the event, I’ll suggest precise next-step values.Chalama Reddy
04/15/2026, 1:01 PMMarvin
04/15/2026, 1:01 PMMarvin
04/15/2026, 1:02 PMredis-cli -u "<redis://host:6379/0>" XPENDING events task-run-recorder
redis-cli -u "<redis://host:6379/0>" XPENDING events task-run-recorder - + 20
redis-cli -u "<redis://host:6379/0>" XINFO CONSUMERS events task-run-recorder
- What to look for:
- Large pending count and very high idle times (approaching the 15m window) = messages were claimed but not acked (recorder isn’t flushing).
- If you see one consumer with huge pending and long idle, that pod may be failing/retrying and holding the batch.
Step 2 — Confirm recorder is retrying/blocked (enable DEBUG logs)
- On your API/background-service pods:
PREFECT_LOG_LEVEL=DEBUG
PREFECT_LOGGING_EXTRA_LOGGERS="prefect.server.services.task_run_recorder,prefect.server.events.messaging,prefect_redis.messaging"
- Restart pods and watch for:
- “read N messages”, “flushed N task runs / M states”
- Deadlock/retry messages (e.g., DeadlockDetectedError)
- Backoff logs between retries
Step 3 — Apply targeted tuning (reduce overlap, keep throughput)
- Keep broker reads high (drain Redis quickly), write smaller DB batches (shorter transactions), and stagger flushes across clusters:
- Cluster A
PREFECT_SERVER_SERVICES_TASK_RUN_RECORDER_READ_BATCH_SIZE=100
PREFECT_SERVER_SERVICES_TASK_RUN_RECORDER_BATCH_SIZE=20
PREFECT_SERVER_SERVICES_TASK_RUN_RECORDER_FLUSH_INTERVAL=1.00
- Cluster B
PREFECT_SERVER_SERVICES_TASK_RUN_RECORDER_READ_BATCH_SIZE=100
PREFECT_SERVER_SERVICES_TASK_RUN_RECORDER_BATCH_SIZE=20
PREFECT_SERVER_SERVICES_TASK_RUN_RECORDER_FLUSH_INTERVAL=1.15
- This reduces row lock overlap across recorder pods and avoids synchronized commits.
Step 4 — Ensure the consumer framework will reprocess “stuck” messages quickly
- Confirm these (defaults are typically fine, but set explicitly to be sure):
PREFECT_REDIS_MESSAGING_CONSUMER_SHOULD_PROCESS_PENDING_MESSAGES=true
PREFECT_REDIS_MESSAGING_CONSUMER_MIN_IDLE_TIME=5000
- This ensures pending messages are revisited quickly if a consumer idles/crashes.
Step 5 — Database visibility and safety guards
- Enable deadlock diagnostics so we can see the exact cycle and confirm it’s the recorder upserts:
ALTER SYSTEM SET deadlock_timeout = '2s';
ALTER SYSTEM SET log_lock_waits = on;
SELECT pg_reload_conf();
- Check the state rows when the gap occurs:
SELECT id, state_type, name, timestamp
FROM task_run_states
WHERE task_run_id = '<TASK_RUN_ID>'
ORDER BY timestamp DESC
LIMIT 5;
SELECT state_id, state_type, state_timestamp
FROM task_runs
WHERE id = '<TASK_RUN_ID>';
- If neither table has the new state during the delay, the recorder hasn’t committed yet (retry/backoff). If you’re seeing frequent deadlocks, consider further reducing batch_size (e.g., 10–15) and/or temporarily running a single global recorder to validate.
Optional — One-recorder baseline
- Temporarily disable the recorder in one cluster and keep a single recorder running. If the 15m gap disappears, the cause is cross-pod write overlap. Reintroduce the second recorder with small batch_size and staggered flush_interval.
Other sanity checks
- All recorder pods must point to the Postgres writer (not a reader or mixed proxy).
- If PgBouncer is in the path, prefer session pooling for recorder connections and ensure pool size is sufficient.
If you can share:
- Output of XPENDING/XINFO for the recorder group during the gap,
- A few DEBUG log lines from the recorder around a delayed commit,
- One Postgres deadlock report,Marvin
04/15/2026, 1:02 PMChalama Reddy
04/15/2026, 1:39 PMMarvin
04/15/2026, 1:39 PMMarvin
04/15/2026, 1:39 PMPREFECT_SERVER_SERVICES_TASK_RUN_RECORDER_READ_BATCH_SIZE=100
PREFECT_SERVER_SERVICES_TASK_RUN_RECORDER_BATCH_SIZE=20
PREFECT_SERVER_SERVICES_TASK_RUN_RECORDER_FLUSH_INTERVAL=1.00
- Cluster B:
PREFECT_SERVER_SERVICES_TASK_RUN_RECORDER_READ_BATCH_SIZE=100
PREFECT_SERVER_SERVICES_TASK_RUN_RECORDER_BATCH_SIZE=20
PREFECT_SERVER_SERVICES_TASK_RUN_RECORDER_FLUSH_INTERVAL=1.15
- This reduces DB lock overlap and avoids synchronized commits.
- Keep the publisher’s flush cadence short (less batching, less reordering)
- On API/worker processes that publish events:
PREFECT_REDIS_MESSAGING_PUBLISHER_BATCH_SIZE=50
PREFECT_REDIS_MESSAGING_PUBLISHER_PUBLISH_EVERY=1
- Confirm the recorder is healthy (to avoid 15m “corrections”)
- Redis DB 0:
- XINFO GROUPS events and XINFO CONSUMERS events task-run-recorder
- XPENDING events task-run-recorder (pending size/idle should not balloon)
- Enable DEBUG logs for the recorder to see “read N messages” / “flushed N task runs” and any retries:
PREFECT_LOG_LEVEL=DEBUG
PREFECT_LOGGING_EXTRA_LOGGERS="prefect.server.services.task_run_recorder,prefect.server.events.messaging,prefect_redis.messaging"
- Database deadlock/backoff visibility
- If you still see multi‑minute “RUNNING until it corrects,” the COMPLETED upsert is likely retrying after deadlocks. Enable Postgres diagnostics:
ALTER SYSTEM SET deadlock_timeout = '2s';
ALTER SYSTEM SET log_lock_waits = on;
SELECT pg_reload_conf();
- If deadlocks appear, keep DB batch_size = 10–20, and consider temporarily running a single global recorder to validate the fix, then add the second back.
If you want to avoid reliance on event order entirely
- Ensure your runs use server‑side state setting (…/task_runs/<id>/set_state); those DB writes are synchronous and don’t depend on event ordering. The Task Run Recorder is only authoritative for client‑orchestrated task events. If your logs show no set_state calls, you’re on the client path and the recorder is the authority.
Sanity checks
- All recorder pods point to the same Postgres writer (not a reader/mixed proxy).
- Single Redis broker and single consumer group for the recorder (task-run-recorder).
- No big clock skew across clusters.
If you can share:
- A brief recorder DEBUG log window around one run (showing read/flush/retry),
- XPENDING summary during the gap,
- One Postgres deadlock report (if any),
…I can confirm whether backlog/retry is the culprit and suggest exact batch sizes/replica counts to eliminate the “RUNNING until it corrects” window.