Skip to content

Commit 64e7bc6

Browse files
committed
Add retention functions for the two unbounded tables
Nothing in this schema has ever deleted anything -- no retention, no pg_cron, no TTL anywhere in any migration or cron route. Two tables grow forever: channel_snapshots (one jsonb row per channel per sync, ~30 connectors, daily) and agent_messages (one row per message of every agent transcript). The live project is currently reporting Unhealthy on micro compute, and this is the leading explanation. This migration deliberately deletes nothing. It installs two batched prune functions and the indexes they scan on. A bulk DELETE against an instance that is already disk-pressured produces WAL and dead tuples faster than autovacuum reclaims them, which makes things worse before better -- so the order has to be raise headroom, prune in batches, then VACUUM to actually return space to the filesystem. Deleting rows alone does not shrink the database on disk. prune_channel_snapshots never deletes the newest row per (brand_id, channel, provider): a snapshot table is not a log, and that row is the current known state of the channel. Pruning purely by age would silently erase the last reading for any channel that stopped syncing. Both functions are SECURITY DEFINER with the default PUBLIC execute grant revoked, matching 009. The header carries the query to identify which table is actually large, because if channel_snapshots is not near the top then pruning it will not help. Not applied -- no migration should run against this project until it is healthy.
1 parent 118c80e commit 64e7bc6

1 file changed

Lines changed: 149 additions & 0 deletions

File tree

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
-- 010: retention for the two tables that grow without bound.
2+
--
3+
-- Nothing in this schema has ever deleted anything. Grep across every migration
4+
-- and every cron route for "delete from", pg_cron, retention, or vacuum returns
5+
-- nothing. Two tables accumulate forever:
6+
--
7+
-- channel_snapshots -- one row per channel per sync, snapshot_data jsonb.
8+
-- The sync-all cron runs daily across ~30 connectors.
9+
-- agent_messages -- one row per message of every agent transcript, with
10+
-- full content text plus metadata jsonb.
11+
--
12+
-- IMPORTANT -- this migration does not delete anything. It only installs the
13+
-- functions. A bulk DELETE against an instance that is already disk-pressured
14+
-- generates WAL and dead tuples faster than autovacuum reclaims them, so it
15+
-- makes the problem worse before better. The intended order is:
16+
--
17+
-- 1. Raise compute/disk so there is headroom.
18+
-- 2. Call the prune functions repeatedly in small batches (they are designed
19+
-- to be called in a loop and report what they removed).
20+
-- 3. VACUUM (or VACUUM FULL, which takes an exclusive lock) during a quiet
21+
-- window to return the space to the filesystem.
22+
--
23+
-- Deleting rows alone will not shrink the database on disk. Step 3 is the one
24+
-- that actually frees space.
25+
26+
-- ---------------------------------------------------------------------------
27+
-- channel_snapshots
28+
-- ---------------------------------------------------------------------------
29+
-- A snapshot table is not a log: the newest row per (brand, channel, provider)
30+
-- IS the current known state of that channel. Deleting purely by age would
31+
-- silently erase the latest reading for any channel that stopped syncing, so
32+
-- the most recent row per series is always kept regardless of age.
33+
34+
create or replace function public.prune_channel_snapshots(
35+
p_keep_days integer default 90,
36+
p_batch_size integer default 5000
37+
)
38+
returns integer as $$
39+
declare
40+
v_deleted integer;
41+
begin
42+
with candidates as (
43+
select id
44+
from (
45+
select
46+
id,
47+
created_at,
48+
row_number() over (
49+
partition by brand_id, channel, provider
50+
order by created_at desc
51+
) as recency
52+
from public.channel_snapshots
53+
) ranked
54+
where recency > 1 -- never the latest
55+
and created_at < now() - make_interval(days => p_keep_days)
56+
limit p_batch_size
57+
)
58+
delete from public.channel_snapshots s
59+
using candidates c
60+
where s.id = c.id;
61+
62+
get diagnostics v_deleted = row_count;
63+
return v_deleted;
64+
end;
65+
$$ language plpgsql security definer set search_path = public, pg_temp;
66+
67+
comment on function public.prune_channel_snapshots(integer, integer) is
68+
'Deletes up to p_batch_size channel_snapshots older than p_keep_days, always '
69+
'preserving the most recent row per (brand_id, channel, provider). Returns '
70+
'the number deleted; call in a loop until it returns 0.';
71+
72+
-- ---------------------------------------------------------------------------
73+
-- agent_messages
74+
-- ---------------------------------------------------------------------------
75+
-- Pure transcript log. agent_runs.output retains the result of each run, so
76+
-- pruning messages loses the step-by-step trace, not the deliverable. Rows also
77+
-- cascade when their agent_run is deleted.
78+
79+
create or replace function public.prune_agent_messages(
80+
p_keep_days integer default 30,
81+
p_batch_size integer default 5000
82+
)
83+
returns integer as $$
84+
declare
85+
v_deleted integer;
86+
begin
87+
with candidates as (
88+
select id
89+
from public.agent_messages
90+
where created_at < now() - make_interval(days => p_keep_days)
91+
limit p_batch_size
92+
)
93+
delete from public.agent_messages m
94+
using candidates c
95+
where m.id = c.id;
96+
97+
get diagnostics v_deleted = row_count;
98+
return v_deleted;
99+
end;
100+
$$ language plpgsql security definer set search_path = public, pg_temp;
101+
102+
comment on function public.prune_agent_messages(integer, integer) is
103+
'Deletes up to p_batch_size agent_messages older than p_keep_days. Returns '
104+
'the number deleted; call in a loop until it returns 0.';
105+
106+
-- Both functions are SECURITY DEFINER, so the default EXECUTE grant to PUBLIC
107+
-- has to go -- otherwise any authenticated user could call them.
108+
revoke execute on function public.prune_channel_snapshots(integer, integer) from public;
109+
revoke execute on function public.prune_channel_snapshots(integer, integer) from anon;
110+
revoke execute on function public.prune_channel_snapshots(integer, integer) from authenticated;
111+
grant execute on function public.prune_channel_snapshots(integer, integer) to service_role;
112+
113+
revoke execute on function public.prune_agent_messages(integer, integer) from public;
114+
revoke execute on function public.prune_agent_messages(integer, integer) from anon;
115+
revoke execute on function public.prune_agent_messages(integer, integer) from authenticated;
116+
grant execute on function public.prune_agent_messages(integer, integer) to service_role;
117+
118+
-- Age-ordered lookups are what both functions scan on.
119+
create index if not exists channel_snapshots_created_at_idx
120+
on public.channel_snapshots (created_at);
121+
122+
create index if not exists agent_messages_created_at_idx
123+
on public.agent_messages (created_at);
124+
125+
-- ---------------------------------------------------------------------------
126+
-- Finding out what is actually big, before deleting anything
127+
-- ---------------------------------------------------------------------------
128+
-- Run this first. If channel_snapshots is not near the top, the disk problem is
129+
-- somewhere else and pruning it will not help.
130+
--
131+
-- select relname,
132+
-- pg_size_pretty(pg_total_relation_size(c.oid)) as total,
133+
-- n_live_tup
134+
-- from pg_class c
135+
-- join pg_stat_user_tables s on s.relid = c.oid
136+
-- where c.relkind = 'r'
137+
-- order by pg_total_relation_size(c.oid) desc
138+
-- limit 15;
139+
--
140+
-- Then prune in batches, e.g.:
141+
--
142+
-- select public.prune_channel_snapshots(90, 5000); -- repeat until it returns 0
143+
-- select public.prune_agent_messages(30, 5000); -- repeat until it returns 0
144+
--
145+
-- Then reclaim. VACUUM FULL takes an ACCESS EXCLUSIVE lock and needs free space
146+
-- equal to the table size, so on a small instance prefer pg_repack, or vacuum
147+
-- one table at a time during a quiet window:
148+
--
149+
-- vacuum full analyze public.channel_snapshots;

0 commit comments

Comments
 (0)