← Blog

Databases

Postgres audit voor AI-agent event tables: de checklist

Het is zondagavond. Je agent_events table telt 41 miljoen rijen. Je moet er 80% van weghalen. De autovacuum worker heeft andere plannen met je avond.

Jacob Molkenboer· Oprichter · A Brand New Company· 3 okt 2025· 7 min
Open houten kaartenbak op ivoren bureau, messing tabblad met limegroen label, stapel grootboekpapier, rode lakzegel.

Het is zondagavond. De agent_events table telt 41 miljoen rijen. Het product team wil er 80% van afsnijden voordat de Europese werkdag begint, want de chat agent logt elke tool call sinds maart en de schijf maakt nu meer herrie dan de kantoorkoelkast. Je opent psql. Je typt DELETE FROM agent_events WHERE created_at < now() - interval '30 days'. Dan herinner je je de vorige keer.

De autovacuum worker had andere plannen met je avond.

Dit is de checklist die we bij ABN op elke Postgres event table draaien die achter een langlopende AI-agent staat. Op dit moment draaien er veertien agents in productie en twaalf daarvan schrijven naar een event log met veel churn: tool calls, modelantwoorden, retry-pogingen, gebruikersfeedback, alles. Elk van die tables groeit op een gegeven moment over zijn retention policy heen. De vraag is alleen of je daar op dinsdagochtend tijdens planning achter komt, of om 23:47 op een zondag als de disk-full alert je wakker maakt.

De HN-thread die aan de muur hoort

Een uitspraak die we permanent in een tab hebben staan: de enige schaalbare delete in Postgres is DROP TABLE. Dat is bewust overdreven, zoals goede slogans dat zijn. De onderliggende mechaniek klopt. Een rij-voor-rij DELETE schrijft een dead tuple per rij, wat betekent dat autovacuum de hele table moet doorlopen om ruimte vrij te maken, wat betekent dat de table op schijf groeit terwijl jij hem probeert te krimpen. Vermenigvuldig dat met 41 miljoen rijen in één zondagavond-transactie en je hebt zelf een lange avond geregeld.

De uitweg is structureel. Partitioneer de table zodat elke maand events een child relation wordt. Drop de child. Het verwijderen is één DDL-statement en een vrijwel directe metadata-wijziging. Autovacuum blijft slapen. Je schijfgrafiek zakt in een nette stap, in plaats van een lange moerasperiode. De officiële partitioning docs zijn kort en het waard om één keer per jaar te herlezen.

De eerste as van de checklist is dus simpel: hoe dicht zit deze table bij partition-by-range, en hoe ver zit hij van een partitie-voor-partitie drop?

De vijfpunts audit

Draai dit tegen elke event table die je beheert. Geef elk punt een score van 0, 1 of 2. Een table die op 8 of hoger uitkomt, overleeft op zondagavond een purge van 41M rijen zonder autovacuum vast te zetten. Een table die op 5 of lager scoort, is degene die je wakker maakte.

1. Klaar voor partition-by-range

Twee dingen tellen. De table heeft een monotoon stijgende tijdkolom nodig (created_at, event_ts, wat dan ook append-only). Er mogen geen foreign keys van buiten de partition tree naar binnen wijzen, of als ze er zijn, moet de FK de partition key bevatten. Score 2 als de table al per maand is gepartitioneerd. Score 1 als hij een schone tijdkolom heeft en geen vervelende inkomende FKs. Score 0 als het halve schema ernaar verwijst via id.

-- quick check: is this table already a partitioned parent?
SELECT relname, relkind
FROM pg_class
WHERE relname = 'agent_events';
-- relkind 'p' = partitioned table, 'r' = plain table

2. Retention-policy lag in pg_stat_progress_vacuum

Kijk in pg_stat_progress_vacuum tijdens een echte workload, niet in dev. Zie je je event table daar langer dan een paar minuten achter elkaar staan, dan loopt je retention policy achter op de realiteit. Score 2 als autovacuum nooit voor deze table verschijnt buiten een freeze. Score 1 als hij verschijnt maar binnen een uur klaar is. Score 0 als de table permanent in de scanning-heap fase staat.

SELECT
  p.pid,
  c.relname,
  p.phase,
  p.heap_blks_scanned,
  p.heap_blks_total,
  round(100.0 * p.heap_blks_scanned / nullif(p.heap_blks_total, 0), 1) AS pct
FROM pg_stat_progress_vacuum p
JOIN pg_class c ON c.oid = p.relid
ORDER BY pct DESC;

De progress reporting docs leggen elke fase uit. De fase om in de gaten te houden bij een event table is vacuuming indexes: heeft de table zes btree-indexes en heb je net drie miljoen rijen verwijderd, dan brengt autovacuum daar het grootste deel van zijn leven door, niet op de heap.

3. Index-footprint

Elke index is een belasting op elke write en elke delete. Op een event table die er is om aan toegevoegd en daarna gedropt te worden, is drie indexes ruim. Vijf is verdacht. Acht betekent dat iemand er eentje per dashboard-query bij heeft gezet en nooit is teruggekomen. Score 2 als de table maximaal drie indexes heeft en één daarvan op de partition key staat. Score 1 bij vier tot zes. Score 0 als er meer dan zes zijn, of als er één op een JSONB-kolom zit die door de dashboards niet meer wordt gequeryd.

SELECT
  s.indexrelname AS index_name,
  pg_size_pretty(pg_relation_size(s.indexrelid)) AS size,
  s.idx_scan AS scans_since_reset
FROM pg_stat_user_indexes s
WHERE s.relname = 'agent_events'
ORDER BY pg_relation_size(s.indexrelid) DESC;

De scans_since_reset kolom is degene die discussies beëindigt. Heeft een index nul scans over een maand productieverkeer, dan verdient hij zijn plek op writes niet.

4. Overleeft de table een bulk-delete

Dit is de vraag die de HN-thread scherp stelt. Kun je deze table met 80% verkleinen in één operatie zonder dat de database besluit zichzelf de komende acht uur te herschrijven? Score 2 als je DROP TABLE op een oude partitie kunt draaien. Score 1 als je DELETE in batches moet doen, maar een chunked job met LIMIT 50000 binnen een uur klaar is. Score 0 als de enige route één DELETE-statement en een schietgebedje is.

Waarschuwing

Batched deletes schrijven nog steeds dead tuples. Ze zijn vriendelijker voor replication lag dan één enorme transactie, maar ze redden je niet van autovacuum. Scoort een table hier een 1, dan is de echte fix om hem volgend kwartaal naar een 2 te tillen.

5. Inzicht in het schrijfpatroon van de agent

Je kunt niet retention-trimmen wat je niet kunt tellen. Score 2 als je uit je hoofd weet hoeveel rijen per minuut de agent schrijft en wat de mediane rijgrootte is. Score 1 als je het in Grafana kunt opzoeken. Score 0 als je de owner van de agent moet vragen, en die het op zijn beurt aan het model moet vragen.

Dit klinkt zacht. Het is het meest voorspellend van de vijf. Elke event table die we ooit hebben moeten redden, scoorde hier eerst een nul.

Drie tables die een zondagavond-purge overleven

Uit onze eigen portefeuille: de tables die op 8 of hoger door de audit komen, delen drie eigenschappen. Het is de moeite om ze te benoemen, want het zijn ook de tables die het goedkoopst in beheer zijn.

De eerste is de canonieke agent_events, gepartitioneerd op created_at in maandelijkse children, met één composite index op (agent_id, created_at). Retention is een cron die DROP TABLE agent_events_yYYYY_mMM draait voor elke maand ouder dan de policy. De purge is een metadata-wijziging. Autovacuum krijgt hem nooit te zien.

De tweede is tool_call_log. Dezelfde partitievorm, plus een BRIN-index op created_at in plaats van een btree. BRIN op een strikt append-only timestamp-kolom is een paar kilobyte per miljoen rijen en effectief gratis in onderhoud. De BRIN-intro is het waard om één keer te lezen als je dat nog niet hebt gedaan, want het patroon past schoon op bijna elke event-log table.

De derde is model_response_cache. Deze is niet gepartitioneerd, maar scoort een 8 omdat hij klein is (onder het miljoen rijen), precies één index heeft, en zijn retention-regel ('s nachts tijdens lage traffic de laatste 90 dagen behouden) als chunked batch draait. Niet elke event table heeft partities nodig. Sommige hebben minder kolommen en een eerlijke cron nodig.

Wat de audit in de praktijk verandert

Het punt van scoren is geen cijfer geven. Het is de volgende beslissing duidelijk maken. Een table met een 4 heeft geen heldhaftige zondagavond-interventie nodig. Hij heeft één kwartaal werk nodig om hem partitioneerbaar te maken, waarna de zondagavond-interventie een cron van één regel wordt.

We draaien deze audit als we een database overnemen van een vorige leverancier, als een agent op het punt staat om naar tien of honderd keer zijn huidige schaal te gaan, en elke zes maanden op tables die we al beheren. Het kost ongeveer een uur per database. Het scheelt ruwweg één weekend per table per jaar, en dat is de enige ROI-berekening die wij overtuigend vinden om 02:00 uur.

Waar we bij een klant tegenaan liepen

Toen we de chat agent bouwden voor een logistiek bedrijf in Rotterdam, liepen we in maand vier precies tegen het autovacuum-vast-op-indexes patroon uit punt 2 aan. De event table was gegroeid naar 28 miljoen rijen met zeven indexes, waarvan er drie door de dashboards niet meer werden gebruikt. We hebben de ongebruikte indexes gedropt, vanaf dat moment per maand gepartitioneerd, en de geschiedenis in een weekend naar de nieuwe vorm gemigreerd. De purge die drie weken vastzat, draaide de zondag erna in 200 milliseconden. Dat is het type werk dat onze AI-agents praktijk doet zodra de agent live staat en de data-laag de bottleneck wordt.

Kies vandaag één event table. Draai de vijfpunts check. Scoort iets een nul, zet dan een halve dag in de agenda van volgende week. Dat is de hele audit.

Kern

Kun je je event table niet met 80% verkleinen via één DROP TABLE statement, dan sla je hem verkeerd op, niet verkeerd weg.

FAQ

Hoe vaak moet ik deze audit draaien?

Elke zes maanden op tables die je al beheert, en meteen als je een database overneemt van een vorige leverancier of platform. Nieuwe tables krijgen een audit voordat ze naar productie gaan.

Is BRIN altijd beter dan btree voor event tables?

Voor strikt append-only timestamp-kolommen: ja. Heb je daarnaast exact-match lookups op agent_id of user_id nodig, hou daar dan een btree op. BRIN vervangt de timestamp-btree, niet elke index.

Kan ik een table partitioneren die al 41M rijen heeft?

Ja, maar de migratie is zwaarder dan vanaf dag één partitioneren. Gebruik pg_partman of doe het in een onderhoudsvenster met een swap-table aanpak en een gebatchte backfill.

Waarom niet gewoon een TTL-feature gebruiken, zoals in sommige NoSQL stores?

Postgres heeft geen ingebouwde TTL. Partities plus een drop-old-children cron is het equivalent, en geeft je de metadata-only delete die zware load overleeft.

ai agentsarchitectureoperationstoolingworkflow

Iets bouwen?

Start een project