Zach Gelfond
09/26/2025, 2:46 PMVACUUM ANALYZE [table name]
is not significantly dropping the dead tuple counts. Before we run pg_repack
or if that doesn't resolve it VACUUM FULL [table name]
, we want to attempt to fix the issue by modifying Prefect configs. Do you have any suggestions? Additionally, can you help us understand why so many dead tuples are forming and why they don't seem to decrease.Marvin
09/26/2025, 2:46 PMMarvin
09/26/2025, 2:57 PMMarvin
09/26/2025, 2:57 PMWhy VACUUM ANALYZE didn’t “drop the counts”
- If you’re reading n_dead_tup from pg_stat_user_tables, it’s approximate and only refreshed by ANALYZE/autoanalyze. After VACUUM ANALYZE you should see the estimate update — unless VACUUM could not clean or the stats collector hasn’t updated yet.
- Most importantly, VACUUM cannot remove dead tuples if:
- There are long-running transactions (including idle in transaction) holding old snapshots
- A replica with hot_standby_feedback=on has long-running queries
- Logical replication slots or walsenders are holding xmin back
- Check with these queries:
-- Dead tuple / vacuum stats for the tables in question
SELECT relname, n_live_tup, n_dead_tup, vacuum_count, autovacuum_count,
last_vacuum, last_autovacuum, analyze_count, last_analyze, last_autoanalyze
FROM pg_stat_user_tables
WHERE relname IN ('deployment','automation_event_follower','work_queue','automation_bucket');
-- Any long-running transactions
SELECT pid, usename, state, xact_start, now()-xact_start AS xact_age, query
FROM pg_stat_activity
WHERE datname = current_database()
AND xact_start IS NOT NULL
ORDER BY xact_start;
-- Autovacuum activity/progress
SELECT * FROM pg_stat_progress_vacuum;
-- Replication/slots that can pin xmin
SELECT * FROM pg_stat_replication;
SELECT slot_name, slot_type, active, restart_lsn, confirmed_flush_lsn
FROM pg_replication_slots;
-- See what VACUUM says explicitly
VACUUM (VERBOSE, ANALYZE) public.work_queue;
PostgreSQL-side mitigations for these specific “hot but tiny” tables
Even after reducing Prefect write frequency, tuning Postgres usually makes the biggest difference for this pattern:
- Per-table autovacuum thresholds (trigger vacuums sooner)
ALTER TABLE public.work_queue SET (
autovacuum_vacuum_scale_factor = 0.01, -- 1% of pages
autovacuum_vacuum_threshold = 50, -- or even lower for tiny tables
autovacuum_analyze_scale_factor= 0.02
);
ALTER TABLE public.deployment SET (autovacuum_vacuum_scale_factor = 0.01, autovacuum_vacuum_threshold = 50);
ALTER TABLE public.automation_event_follower SET (autovacuum_vacuum_scale_factor = 0.01, autovacuum_vacuum_threshold = 50);
ALTER TABLE public.automation_bucket SET (autovacuum_vacuum_scale_factor = 0.01, autovacuum_vacuum_threshold = 50);
- Enable HOT updates by leaving room on pages (reduces index churn on frequent updates)
ALTER TABLE public.work_queue SET (fillfactor = 80);
ALTER TABLE public.deployment SET (fillfactor = 80);
-- REWRITE once to apply fillfactor to all pages
VACUUM FULL public.work_queue; -- or pg_repack to avoid long locks
VACUUM FULL public.deployment;
```
- Temporarily increase autovacuum aggressiveness globally while you clean up (optional):
- autovacuum_vacuum_cost_limit higher
- autovacuum_naptime lower
- log_autovacuum_min_duration=0 (to see what’s happening)
- If replicas exist, confirm they aren’t pinning cleanup:
- Avoid long-running queries on replicas
- Consider hot_standby_feedback=off if acceptable, or kill stale replica queries so VACUUM can clean
Will you still need pg_repack/VACUUM FULL?
- Likely yes for the already-bloated relations. VACUUM cannot shrink file size; pg_repack or VACUUM FULL is the usual one-time fix. The Prefect and Postgres changes above are to prevent it from happening again (or at least make it much slower to re-accumulate).
Helpful references
- Settings reference (all env vars map to these settings): Prefect settings reference
- Worker health/polling: Worker healthchecksMarvin
09/26/2025, 2:57 PMZach Gelfond
09/26/2025, 3:46 PM[
{
"relname": "deployment",
"n_live_tup": "23",
"n_dead_tup": "729162",
"vacuum_count": "1",
"autovacuum_count": "3606",
"last_vacuum": "2025-09-26 13:43:03.068695+00",
"last_autovacuum": "2025-09-26 15:44:54.261092+00",
"analyze_count": "1",
"last_analyze": "2025-09-26 13:43:03.147411+00",
"last_autoanalyze": "2025-09-26 15:44:55.315895+00"
},
{
"relname": "work_queue",
"n_live_tup": "7",
"n_dead_tup": "159001",
"vacuum_count": "2",
"autovacuum_count": "3973",
"last_vacuum": "2025-09-26 15:30:04.158969+00",
"last_autovacuum": "2025-09-26 15:44:43.740111+00",
"analyze_count": "2",
"last_analyze": "2025-09-26 15:30:04.194517+00",
"last_autoanalyze": "2025-09-26 15:44:43.897996+00"
},
{
"relname": "automation_bucket",
"n_live_tup": "49",
"n_dead_tup": "621554",
"vacuum_count": "1",
"autovacuum_count": "3476",
"last_vacuum": "2025-09-26 13:21:05.602454+00",
"last_autovacuum": "2025-09-26 15:44:15.25604+00",
"analyze_count": "1",
"last_analyze": "2025-09-26 13:21:05.635403+00",
"last_autoanalyze": "2025-09-26 15:21:15.756233+00"
},
{
"relname": "automation_event_follower",
"n_live_tup": "34",
"n_dead_tup": "376346",
"vacuum_count": "0",
"autovacuum_count": "3929",
"last_vacuum": null,
"last_autovacuum": "2025-09-26 15:44:20.581614+00",
"analyze_count": "0",
"last_analyze": null,
"last_autoanalyze": "2025-09-26 15:31:45.765308+00"
}
]
Marvin
09/26/2025, 3:52 PMMarvin
09/26/2025, 3:53 PMwork_queue
and related status rows.
- Server loops (foreman/scheduler) also touch deployment
frequently.
- Your one automation drives writes to automation_bucket
and automation_event_follower
.
- Autovacuum is running constantly (3.4k–4k runs) but cannot keep up with the continuous update rate; dead tuples accrue faster than they are reclaimed. VACUUM won’t shrink files even when it reclaims space.
Prefect-side changes (reduce write frequency)
If you can tolerate slightly slower “recency” in the UI and pickup times, these settings will materially reduce bloat:
1) Workers (biggest win for work_queue
)
- Increase polling and heartbeat:
PREFECT_WORKER_QUERY_SECONDS=15 # was 1–5; try 10–15 for high-priority, 30 for others
PREFECT_WORKER_HEARTBEAT_SECONDS=60 # was ~30
- Consider reducing to 1 worker for low-traffic pools.
- Tradeoff: slightly slower pickup and slower unhealthy detection.
- Docs: Worker healthchecks
2) Server services (reduces background writes to work_queue
and deployment
)
PREFECT_SERVER_SERVICES_FOREMAN_LOOP_SECONDS=60
PREFECT_SERVER_SERVICES_SCHEDULER_LOOP_SECONDS=120
PREFECT_SERVER_SERVICES_SCHEDULER_RECENT_DEPLOYMENTS_LOOP_SECONDS=30
- Settings reference: Settings reference
3) Events / Automations (targets automation_*
)
- Since you’re considering removing the automation, you can turn off triggers/event persistence entirely to stop most writes to these tables:
PREFECT_SERVER_SERVICES_TRIGGERS_ENABLED=false
PREFECT_SERVER_SERVICES_EVENT_PERSISTER_ENABLED=false
PREFECT_SERVER_EVENTS_STREAM_OUT_ENABLED=false
- If you must keep automations, increase batching/intervals to reduce churn:
PREFECT_SERVER_EVENTS_PROACTIVE_GRANULARITY=30 # default 5s; fewer bucket updates
PREFECT_SERVER_SERVICES_EVENT_PERSISTER_FLUSH_INTERVAL=30 # default 5s; larger, fewer writes
- Automations API overview: Automations API
- Settings reference: Settings reference
PostgreSQL-side tuning (let autovacuum keep up)
These tables are tiny but “hot”; tune autovacuum aggressively for them and enable HOT updates:
1) Per-table autovacuum thresholds
ALTER TABLE public.work_queue SET (
autovacuum_vacuum_scale_factor = 0.0,
autovacuum_vacuum_threshold = 50,
autovacuum_analyze_scale_factor= 0.0,
autovacuum_analyze_threshold = 50,
autovacuum_vacuum_cost_limit = 2000
);
ALTER TABLE public.deployment SET (
autovacuum_vacuum_scale_factor = 0.0,
autovacuum_vacuum_threshold = 50,
autovacuum_analyze_scale_factor= 0.0,
autovacuum_analyze_threshold = 50,
autovacuum_vacuum_cost_limit = 2000
);
ALTER TABLE public.automation_event_follower SET (
autovacuum_vacuum_scale_factor = 0.0,
autovacuum_vacuum_threshold = 50,
autovacuum_analyze_scale_factor= 0.0,
autovacuum_analyze_threshold = 50,
autovacuum_vacuum_cost_limit = 2000
);
ALTER TABLE public.automation_bucket SET (
autovacuum_vacuum_scale_factor = 0.0,
autovacuum_vacuum_threshold = 50,
autovacuum_analyze_scale_factor= 0.0,
autovacuum_analyze_threshold = 50,
autovacuum_vacuum_cost_limit = 2000
);
- Rationale: for tiny tables, a small absolute threshold is better than the default scale factor. Higher cost limit lets VACUUM finish quickly.
2) Enable HOT updates via fillfactor and rewrite once
```
ALTER TABLE public.work_queue SET (fillfactor = 80);
ALTER TABLE public.deployment SET (fillfactor = 80);Marvin
09/26/2025, 3:53 PM- Then do a one-time rewrite to apply page free space:
- Prefer pg_repack (minimal locking); otherwise `VACUUM FULL` during a maintenance window.
- Example:
-- Using pg_repack (recommended)
SELECT repack.repack_table('public.work_queue');
SELECT repack.repack_table('public.deployment');
SELECT repack.repack_table('public.automation_event_follower');
SELECT repack.repack_table('public.automation_bucket');
3) Optional: reindex the hottest indexes after the rewrite
REINDEX INDEX CONCURRENTLY <index_name>;
(You can identify the largest/bloatiest indexes with pg_stat_user_indexes or pgstattuple.)
Why your VACUUMs didn’t materially reduce the counts
- Your `autovacuum_count` is very high and `last_autovacuum` is recent for all tables — they are running, but dead tuples are re‑created faster than VACUUM reclaims them.
- VACUUM does not shrink file sizes; only marks space reusable. After you slow the churn and do a one-time pg_repack/VACUUM FULL, the size and dead-tuple counters will normalize.
Quick runbook
1) Apply the Prefect settings above (workers, foreman/scheduler, and disable triggers/events if you remove the automation).
2) Set per-table autovacuum params and fillfactor.
3) Run pg_repack once for the four tables to reclaim space.
4) Monitor:
SELECT relname, n_live_tup, n_dead_tup, vacuum_count, autovacuum_count,
last_vacuum, last_autovacuum
FROM pg_stat_user_tables
WHERE relname IN ('deployment','automation_event_follower','work_queue','automation_bucket');
5) Sanity-check blockers while monitoring (just in case):
SELECT pid, usename, state, xact_start, now()-xact_start AS xact_age, query
FROM pg_stat_activity
WHERE datname = current_database() AND xact_start IS NOT NULL
ORDER BY xact_start;
SELECT * FROM pg_stat_progress_vacuum;
```
On removing the automation
- If you switch to flow-level timeouts, disabling the triggers service and event persister will dramatically reduce writes to automation_*
and event tables. That’s the cleanest path given your goals.
If you want, share:
- The number of work pools and whether some are cold; we can suggest pool-specific polling values.
- pg_stat_user_indexes
for these relations; we can point out which indexes to reindex after repack.
References
- Settings reference: Prefect settings reference
- Worker health/polling: Worker healthchecks
- Automations API overview: Automations API
- General DB guidance: Database maintenanceZach Gelfond
09/26/2025, 4:03 PMSELECT pid, usename, state, xact_start, now()-xact_start AS xact_age, query
FROM pg_stat_activity
WHERE datname = current_database() AND xact_start IS NOT NULL
ORDER BY xact_start;
shows 6 active
and 4 idle in transaction
queries over a day old. 1 is an autovacuum on event_resources
(which is currently our biggest table), 2 are inserts on automation_bucket
, and the rest are deletes from event_resources
from when we tried to delete older records in that table to free up space.
Regarding your suggestions, since its important for us to have flow runs start quickly after being created, we likely won't increase polling unless its necessary. The other suggestions are good though.Marvin
09/26/2025, 4:03 PMMarvin
09/26/2025, 4:05 PM-- terminate "idle in transaction" sessions older than 10 minutes
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE datname = current_database()
AND state = 'idle in transaction'
AND now() - xact_start > interval '10 minutes';
- Enforce timeouts so it doesn’t recur:
-- Global (requires superuser)
ALTER SYSTEM SET idle_in_transaction_session_timeout = '5min';
SELECT pg_reload_conf();
-- Optionally protect the app by limiting long statements too
-- (set per role/database so you don't disrupt admin operations)
ALTER ROLE prefect SET statement_timeout = '2min';
- For your maintenance scripts/tools: ensure they COMMIT promptly and avoid leaving sessions open after large operations.
2) Fix your event_resources cleanup process
Large, monolithic DELETEs cause long transactions and huge bloat. Switch to batched deletes:
-- repeat this until 0 rows; keep each transaction short
WITH c AS (
SELECT id
FROM event_resources
WHERE created < now() - interval '7 days'
ORDER BY id
LIMIT 10000
)
DELETE FROM event_resources e
USING c
WHERE e.id = c.id;
VACUUM (ANALYZE) event_resources; -- run periodically between batches
- Ensure there’s an index supporting your retention predicate (e.g., on created).
- Even better long-term: time-based partitioning so you can DROP old partitions instead of DELETE. Prefect has a nice walkthrough here: Partitioning without downtime.
3) Consider your events/automations choice
Since you’re open to replacing the automation with flow-level timeouts:
- Disabling these will immediately slash writes to automation_* and events tables:
PREFECT_SERVER_SERVICES_TRIGGERS_ENABLED=false
PREFECT_SERVER_SERVICES_EVENT_PERSISTER_ENABLED=false
PREFECT_SERVER_EVENTS_STREAM_OUT_ENABLED=false
- If you keep automations, reduce cadence/batch:
PREFECT_SERVER_EVENTS_PROACTIVE_GRANULARITY=30
PREFECT_SERVER_SERVICES_EVENT_PERSISTER_FLUSH_INTERVAL=30
Refs: Settings reference, Automations API
4) Keep fast starts while cutting some churn
You can keep 1–5s polling on your highest-priority pool and:
- Use 1 worker (not 2) for low-traffic pools, or
- Keep those at 10–15s polling
This preserves fast starts where you need them while reducing background updates to work_queue overall.
Postgres tuning for your “hot but tiny” tables
These help autovacuum keep up and reduce index churn:
- Per-table autovacuum thresholds (tiny absolute thresholds work best for small hot tables):
```
ALTER TABLE public.work_queue SET (
autovacuum_vacuum_scale_factor = 0.0,
autovacuum_vacuum_threshold = 50,
autovacuum_analyze_scale_factor= 0.0,
autovacuum_analyze_threshold = 50,
autovacuum_vacuum_cost_limit = 2000
);
ALTER TABLE public.deployment SET (
autovacuum_vacuum_scale_factor = 0.0,
autovacuum_vacuum_threshold = 50,
autovacuum_analyze_scale_factor= 0.0,
autovacuum_analyze_threshold = 50,
autovacuum_vacuum_cost_limit = 2000
);
ALTER TABLE public.automation_event_follower SET (
autovacuum_vacuum_scale_factor = 0.0,
autovacuum_vacuum_threshold = 50,
autovacuum_analyze_scale_factor= 0.0,
autovacuum_analyze_threshold = 50,
autovacuum_vacuum_cost_limit = 2000
);
ALTER TABLE public.automation_bucket SET (
autovacuum_vacuum_scale_factor = 0.0,Marvin
09/26/2025, 4:05 PM- Enable HOT updates by reserving page space; then do a one-time rewrite:
ALTER TABLE public.work_queue SET (fillfactor = 80);
ALTER TABLE public.deployment SET (fillfactor = 80);
ALTER TABLE public.automation_event_follower SET (fillfactor = 80);
ALTER TABLE public.automation_bucket SET (fillfactor = 80);
-- Repack preferred to avoid long locks
SELECT repack.repack_table('public.work_queue');
SELECT repack.repack_table('public.deployment');
SELECT repack.repack_table('public.automation_event_follower');
SELECT repack.repack_table('public.automation_bucket');
- For your huge event_resources work: consider temporarily bumping
- autovacuum_max_workers (e.g., to 5)
- autovacuum_work_mem (larger)
- autovacuum_vacuum_cost_limit (larger)
so autovacuum can finish passes faster.
About the autovacuum running >1 day on event_resources
- That’s expected on very large tables, but it can also be slowed or blocked by those long transactions. Once you terminate the long “idle in transaction” sessions and switch to batched deletes, autovacuum should make visible progress. Use:
SELECT * FROM pg_stat_progress_vacuum WHERE relid::regclass = 'public.event_resources'::regclass;
to see the current phase and heap_blks_vacuumed/total.
Finally, after you:
- Kill long/idle transactions
- Reduce event/automation churn (or disable them)
- Tune autovacuum + fillfactor
Run a one-time pg_repack (or VACUUM FULL during a window) on the four hot tables to reclaim the existing bloat. After that, watch:
SELECT relname, n_live_tup, n_dead_tup, vacuum_count, autovacuum_count,
last_vacuum, last_autovacuum
FROM pg_stat_user_tables
WHERE relname IN ('deployment','automation_event_follower','work_queue','automation_bucket');
```
If you want, share the output of:
- pg_stat_progress_vacuum for event_resources
- pg_stat_user_indexes for those four tables
and I’ll suggest specific index reindexing and confirm autovacuum is now able to clean up promptly.
Useful refs:
- Settings reference: Prefect settings reference
- Worker health/polling: Worker healthchecks
- Partitioning approach: Partitioning without downtime
- General DB guidance: Database maintenanceZach Gelfond
09/26/2025, 5:21 PMpg_stat_progress_vacuum
for the pid associated with the autovacuum on event_resources
that has been going for over a day
[
{
"pid": 3757068,
"datid": 16545,
"datname": "prefect",
"relid": 17526,
"phase": "vacuuming indexes",
"heap_blks_total": "66264099",
"heap_blks_scanned": "16074895",
"heap_blks_vacuumed": "15814322",
"index_vacuum_count": "12",
"max_dead_tuples": "11184809",
"num_dead_tuples": "11184527"
}
]
and here are the top 10 largest indexes of the tables ('event_resources', 'deployment','automation_event_follower','work_queue','automation_bucket')
in pg_stat_user_indexes
:
[
{
"schemaname": "public",
"relname": "event_resources",
"indexrelname": "ix_event_resources__resource_id__occurred",
"indexrelid": 17536,
"index_size": "299 GB",
"idx_scan": "4434",
"idx_tup_read": "170422",
"idx_tup_fetch": "170422"
},
{
"schemaname": "public",
"relname": "event_resources",
"indexrelname": "pk_event_resources",
"indexrelid": 17534,
"index_size": "116 GB",
"idx_scan": "286968305",
"idx_tup_read": "286968305",
"idx_tup_fetch": "286903669"
},
{
"schemaname": "public",
"relname": "event_resources",
"indexrelname": "ix_event_resources__updated",
"indexrelid": 17537,
"index_size": "86 GB",
"idx_scan": "31536",
"idx_tup_read": "1099565722",
"idx_tup_fetch": "59020"
},
{
"schemaname": "public",
"relname": "automation_bucket",
"indexrelname": "uq_automation_bucket__automation_id__trigger_id__bucketing_key",
"indexrelid": 17525,
"index_size": "94 MB",
"idx_scan": "1771367",
"idx_tup_read": "2742369",
"idx_tup_fetch": "432367"
},
{
"schemaname": "public",
"relname": "deployment",
"indexrelname": "trgm_ix_deployment_name",
"indexrelid": 17072,
"index_size": "78 MB",
"idx_scan": "0",
"idx_tup_read": "0",
"idx_tup_fetch": "0"
},
{
"schemaname": "public",
"relname": "automation_event_follower",
"indexrelname": "uq_follower_for_scope",
"indexrelid": 17588,
"index_size": "37 MB",
"idx_scan": "18641332",
"idx_tup_read": "462154",
"idx_tup_fetch": "0"
},
{
"schemaname": "public",
"relname": "automation_event_follower",
"indexrelname": "ix_ae_follower_scope_leader",
"indexrelid": 161333954,
"index_size": "31 MB",
"idx_scan": "0",
"idx_tup_read": "0",
"idx_tup_fetch": "0"
},
{
"schemaname": "public",
"relname": "automation_bucket",
"indexrelname": "ix_automation_bucket__automation_id__end",
"indexrelid": 17470,
"index_size": "25 MB",
"idx_scan": "206772",
"idx_tup_read": "61840214561",
"idx_tup_fetch": "3530"
},
{
"schemaname": "public",
"relname": "automation_event_follower",
"indexrelname": "ix_automation_event_follower__leader_event_id",
"indexrelid": 17522,
"index_size": "24 MB",
"idx_scan": "33065342",
"idx_tup_read": "417684",
"idx_tup_fetch": "28"
},
{
"schemaname": "public",
"relname": "automation_bucket",
"indexrelname": "ix_automation_bucket__updated",
"indexrelid": 17471,
"index_size": "18 MB",
"idx_scan": "0",
"idx_tup_read": "0",
"idx_tup_fetch": "0"
}
]
Marvin
09/26/2025, 5:21 PMMarvin
09/26/2025, 5:23 PM-- kill long idle sessions (run a few times a day via cron)
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE datname = current_database()
AND state = 'idle in transaction'
AND now() - xact_start > interval '10 minutes';
-- prevent recurrence
ALTER SYSTEM SET idle_in_transaction_session_timeout = '5min';
SELECT pg_reload_conf();
2) Reduce (or pause) writes to events/automations while you do maintenance
- Since you’re open to removing the automation, disable these to stop most writes to automation_* and events tables:
PREFECT_SERVER_SERVICES_TRIGGERS_ENABLED=false
PREFECT_SERVER_SERVICES_EVENT_PERSISTER_ENABLED=false
PREFECT_SERVER_EVENTS_STREAM_OUT_ENABLED=false
- If you can’t disable, plan a short maintenance window where you shut down the Prefect server API or just the event persister/triggers to let vacuum and cleanup catch up.
3) Raise memory for vacuum so it can finish index cleanup in fewer passes
- In postgresql.conf:
-- Raise for the maintenance window (and keep higher if you can)
maintenance_work_mem = '2GB' -- or higher if you have RAM
autovacuum_work_mem = '2GB' -- PG14 supports per-worker memory
vacuum_cost_limit = 2000 -- let vacuum do more work per cycle
- Reload and let the autovacuum continue; if you still see it spinning on “vacuuming indexes”, consider a manual run during low-traffic:
VACUUM (VERBOSE, ANALYZE, PARALLEL 4) public.event_resources;
Note: Autovacuum does not use parallel vacuum; a manual VACUUM can. Disable autovac on this table during the manual run if needed:
ALTER TABLE public.event_resources SET (autovacuum_enabled = false);
-- run manual VACUUM, then re-enable:
ALTER TABLE public.event_resources RESET (autovacuum_enabled);
Safer retention cleanup for event_resources
- Avoid long DELETEs (they create long transactions and more bloat). Do small, indexed batches:
1) Ensure there’s an index that matches your retention predicate
- If you delete by occurred, create a dedicated index:
CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_event_resources__occurred
ON public.event_resources (occurred);
(Your largest index is on (resource_id, occurred); that’s not ideal for a simple time-based DELETE.)
2) Batch deletes in small chunks (commit every batch)
-- repeat until 0 rows affected
WITH c AS (
SELECT id
FROM public.event_resources
WHERE occurred < now() - interval '7 days'
ORDER BY id
LIMIT 10000
)
DELETE FROM public.event_resources e
USING c
WHERE e.id = c.id;
VACUUM (ANALYZE) public.event_resources; -- occasionally between batches
- If you delete by updated instead, index updated similarly.
Best long-term: partition event tables by time, then DROP old partitions. See Prefect’s partitioning write-up: Partitioning without downtime.
Reclaiming bloat efficiently
- After you’ve reduced churn and done retention deletes, reclaim space. For event_resources and the four hot control tables:
- Prefer pg_repack (concurrent, minimal locks):
```
SELECT repack.repack_table('public.event_resources');Marvin
09/26/2025, 5:23 PM- If pg_repack isn’t available, use VACUUM FULL in a maintenance window (exclusive lock).
- Reindex the largest/bloatiest indexes after cleanup (especially on event_resources):
REINDEX INDEX CONCURRENTLY public.ix_event_resources__resource_id__occurred;
REINDEX INDEX CONCURRENTLY public.pk_event_resources;
REINDEX INDEX CONCURRENTLY public.ix_event_resources__updated;
Drop obviously-unused indexes to reduce vacuum work
- trgm_ix_deployment_name shows 0 scans. If you don’t need fuzzy search over deployment names, drop it:
DROP INDEX CONCURRENTLY IF EXISTS public.trgm_ix_deployment_name;
(Spot-check with EXPLAIN on your typical queries first.)
Keep fast starts without raising poll intervals
- Keep 1–5s polling on your highest-priority pools.
- For low/medium priority pools, consider:
- 1 worker instead of 2, or
- 10–15s polling on those only.
This reduces total write pressure to work_queue without affecting critical pools.
Per-table autovacuum and HOT for the hot but tiny tables
These help prevent re-bloat on work_queue, deployment, automation_*:
ALTER TABLE public.work_queue SET (
autovacuum_vacuum_scale_factor = 0.0,
autovacuum_vacuum_threshold = 50,
autovacuum_analyze_scale_factor= 0.0,
autovacuum_analyze_threshold = 50,
autovacuum_vacuum_cost_limit = 2000,
fillfactor = 80
);
ALTER TABLE public.deployment SET (
autovacuum_vacuum_scale_factor = 0.0,
autovacuum_vacuum_threshold = 50,
autovacuum_analyze_scale_factor= 0.0,
autovacuum_analyze_threshold = 50,
autovacuum_vacuum_cost_limit = 2000,
fillfactor = 80
);
ALTER TABLE public.automation_event_follower SET (
autovacuum_vacuum_scale_factor = 0.0,
autovacuum_vacuum_threshold = 50,
autovacuum_analyze_scale_factor= 0.0,
autovacuum_analyze_threshold = 50,
autovacuum_vacuum_cost_limit = 2000,
fillfactor = 80
);
ALTER TABLE public.automation_bucket SET (
autovacuum_vacuum_scale_factor = 0.0,
autovacuum_vacuum_threshold = 50,
autovacuum_analyze_scale_factor= 0.0,
autovacuum_analyze_threshold = 50,
autovacuum_vacuum_cost_limit = 2000,
fillfactor = 80
);
```
After setting fillfactor, do a one-time pg_repack for those tables to apply page free space.
Why your autovacuum is cycling
- The “vacuuming indexes” phase shows num_dead_tuples at the memory limit (max_dead_tuples). With gigantic indexes and constant writes, autovacuum must loop many times to clean them. Increasing autovacuum_work_mem and pausing event writes lets it complete in fewer passes.
Helpful Prefect links for the settings we discussed
- Settings reference: Prefect settings reference
- Worker health/polling: Worker healthchecks
- Automations API: Automations API
- Database maintenance: Database maintenance
- Partitioning blog: Partitioning without downtime
If you can share:
- Which column you use for event retention (occurred vs updated)
- The output of EXPLAIN on your retention DELETE
I can confirm the best supporting index and tune the batch size/timing.Zach Gelfond
09/26/2025, 5:28 PMevent_resources
used for anything internally within Prefect other than just logging? Hypothetically, would this issue be resolved temporarily with a truncate?Marvin
09/26/2025, 5:28 PMMarvin
09/26/2025, 5:29 PMPREFECT_SERVER_SERVICES_TRIGGERS_ENABLED=false
PREFECT_SERVER_SERVICES_EVENT_PERSISTER_ENABLED=false
PREFECT_SERVER_EVENTS_STREAM_OUT_ENABLED=false
2) Cancel the long-running autovacuum on event_resources if still active:
SELECT pg_cancel_backend(pid) FROM pg_stat_activity
WHERE pid = <autovacuum_pid> AND query ILIKE '%event_resources%';
3) Truncate event tables. Start with event_resources, but in practice you’ll likely want to clear the entire events store to keep FKs happy and prevent skew:
-- Adjust list to your schema; commonly:
TRUNCATE TABLE public.event_resources, public.events CASCADE;
4) Optionally reclaim index bloat right away (TRUNCATE empties indexes, but if you’d rather rebuild, do it after traffic resumes):
REINDEX TABLE CONCURRENTLY public.event_resources;
REINDEX TABLE CONCURRENTLY public.events;
5) Re-enable services if you still want them writing again later.
If you must keep automations
- TRUNCATE will wipe their history/lookback; some triggers may miss or re-evaluate unexpectedly. If you must keep them, do not truncate. Instead:
- Lower events retention (default is 7d) to a smaller window to keep tables small:
- PREFECT_SERVER_EVENTS_RETENTION_PERIOD=2d (via ServerEventsSettings.retention_period)
- Batch-delete old rows with short transactions, then VACUUM/pg_repack, as we outlined earlier.
Notes/caveats
- TRUNCATE acquires ACCESS EXCLUSIVE lock; do it during a brief maintenance window with events ingestion paused. It is transactional and fast for very large tables.
- TRUNCATE clears table contents and resets visibility; it effectively eliminates table and index bloat for those relations in one shot.
- This will remove historical events from the UI. It does not impact Prefect’s flow/task orchestration or scheduling.
- Root cause remains high write rate + long-lived transactions. Keep the guardrails you implemented (idle_in_transaction_session_timeout, batched deletes, increased autovacuum_work_mem/maintenance_work_mem) to prevent reoccurrence.
Given your plan to move away from the single automation to flow-level timeouts, the cleanest path is:
- Disable triggers and event persister
- TRUNCATE events tables (events + event_resources, plus any related event tables in your schema)
- Keep those services off to prevent re-growth
- Optionally re-enable only after you’ve also reduced retention and memory-tuned autovacuum
If you want, paste your exact list of event-related tables from pg_class/pg_constraint and I’ll give you a precise TRUNCATE set that preserves referential integrity.Zach Gelfond
09/26/2025, 7:05 PMon_completion
, on_failure
, etc within our flow decorators.Marvin
09/26/2025, 7:05 PMMarvin
09/26/2025, 7:06 PMPREFECT_SERVER_SERVICES_TRIGGERS_ENABLED=false
PREFECT_SERVER_SERVICES_EVENT_PERSISTER_ENABLED=false
# optional: keep real-time stream to subscribers/UI sessions without DB writes
PREFECT_SERVER_EVENTS_STREAM_OUT_ENABLED=true
If you prefer to retain some event history instead of turning it off
- Keep the persister enabled but set a short retention to keep tables small (and switch your cleanup to batched deletes):
# e.g., 2 days; use a short window you’re comfortable with
PREFECT_SERVER_EVENTS_RETENTION_PERIOD=2d # or 172800s
- Continue enforcing idle_in_transaction_session_timeout and use batched deletes to avoid long transactions.
Notes
- Logs are separate from events; disabling event persistence does not disable log-to-API.
- If you ever need to “hard reset” the event tables, a TRUNCATE will not impact your on_* hooks or scheduling, but do it in a brief maintenance window with the event persister disabled.
References
- Settings reference (env vars above map here): Prefect settings reference
- Worker health/polling (unrelated to events but useful context): Worker healthchecks
If you want, share how you use the on_* hooks (e.g., sending notifications, cleanup), and I can flag any edge cases. But in typical usage, turning off event persistence won’t affect them.