SaaS-architectuur
Workers in PostgreSQL: retries, crashes en klantcontext
Eén worker voor alle klanten, gemeten in PostgreSQL 17 met psycopg: welke klantcontext de taak overleeft, waarom fouten niet tellen, wat een crash achterlaat.
Een achtergrondworker in een multi-tenant SaaS doet iets wat geen webverzoek doet: hij bedient alle klanten om de beurt, op dezelfde databaseverbinding, dagenlang. Daar gaan drie dingen mis die een test met één klant nooit laat zien. De klantcontext van de ene taak overleeft die taak en staat er nog voor de volgende. Een taak die faalt wordt nooit als mislukt geteld en dus eindeloos opnieuw geprobeerd. En een worker die halverwege een taak sterft, laat die taak achter als lopend voor niemand, of terug in de wachtrij om de volgende worker te laten sterven.
We hebben ze alle drie gemeten met een Python-worker op psycopg 3 en zijn verbindingspool. Met de standaardinstellingen van de pool gaf een taak die slaagde zijn klant door aan de volgende taak — 20.000 facturen van de verkeerde klant — en een taak die faalde niet. Een gifpiltaak werd in de meest gebruikte wachtrijopzet 10 van de 10 keer opgepakt en nooit geteld, en de taak erachter kwam nooit aan de beurt. En een taak die langer duurde dan zijn lease liep twee keer; een fencingcontrole hield de late eerste uitvoering uit de database, maar niet uit de wereld.
De opzet
PostgreSQL 17.10 in een wegwerpcontainer, standaardinstellingen. Python 3.12 met psycopg 3.3.6 en psycopg_pool 3.3.3. Gemeten op 3 oktober 2026.
- 100 klanten en 103.707 facturen, elk
floor(20000 / klant), dus klant 1 heeft er 20.000, klant 2 10.000 en klant 3 6.666. - Row-level security afgedwongen (
force) opinvoices, met de klant in een sessievariabele:using (tenant_id = nullif(current_setting('app.tenant', true), '')::int). De worker logt in als een gewone rol, niet als eigenaar van de tabel. - Een tabel
jobsmetstatus,attempts,max_attempts(3),locked_untilenlast_error, en een tabeljob_runs(job_id,worker,attempt,started,finished) waarin elke taak bij de start één rij schrijft, zodat we uitvoeringen kunnen tellen.
Lekt de klantcontext van de ene taak naar de volgende?
Ja — maar alleen na een taak die slaagt. Een pool van één verbinding draaide drie taken voor klant 1, 2 en 3. Elke taak zette de klant met set_config('app.tenant', …, false), de vorm voor de hele sessie, en telde facturen. Na elke taak vroegen we de pool om een verbinding, zetten niets, en telden opnieuw. De middelste kolom komt uit een tweede reeks waarin de taak voor klant 2 faalt; de andere kolommen waren in beide reeksen gelijk.
| Pool en instelling | Na een taak voor klant 1 | Na een falende taak voor klant 2 | Na een taak voor klant 3 |
|---|---|---|---|
standaardpool, set_config(…, false) | 20.000 rijen, klant ‘1’ | 20.000 rijen, klant ‘1’ | 6.666 rijen, klant ‘3’ |
autocommit=True, set_config(…, false) | 20.000 rijen, klant ‘1’ | 10.000 rijen, klant ‘2’ | 6.666 rijen, klant ‘3’ |
standaardpool, set_config(…, true) | 0 rijen | 0 rijen | 0 rijen |
Het verschil tussen de eerste twee regels is de transactie. psycopg draait standaard niet in autocommit, dus de opdrachten van de taak, de set_config inbegrepen, zitten in één transactie. Verlaat de taak het blok with pool.connection(), dan past de pool het gewone gedrag toe — de transactie committen bij succes en terugdraaien bij een fout, zegt de documentatie van psycopg. Een commit houdt een sessie-instelling vast; een rollback draait hem terug. De falende taak voor klant 2 liet dus niets achter, maar de context van de laatste geslaagde taak, klant 1, stond er nog. In autocommit is er geen transactie om terug te draaien, en lekte ook de falende taak.
Dat maakt dit lek lastig te vinden. Het verschijnt niet als er iets misgaat; het verschijnt als alles goed gaat, bij de volgende taak, voor een andere klant.
De derde regel is de oplossing: set_config('app.tenant', …, true) — of SET LOCAL — binnen de transactie van de taak. De instelling eindigt met de transactie, wat de uitkomst ook is. Het is hetzelfde advies dat we achter PgBouncer maten in PgBouncer en RLS: klantcontext, SET LOCAL, gedeelde plannen; de pool van een worker geeft dezelfde serversessie aan de ene taak na de andere, net als een pooler in transactiemodus.
Wat kost het om de verbinding op te schonen?
Wil je liever na elke taak opruimen dan erop vertrouwen dat elke taak de lokale vorm gebruikt, dan neemt psycopg_pool een reset-functie aan. We maten 2.000 taken per variant — één set_config en één query per taak, de eerste 200 niet meegeteld — en draaiden de reeks twee keer. De laatste variant draaide 300 taken, dus zijn mediaan gaat over 100:
| Opschonen na elke taak | Mediaan per taak | Verbindingen geopend | Achtergebleven context |
|---|---|---|---|
niet, set_config(…, false) | 0,33–0,34 ms | 1 | klant ‘100’ |
niet, set_config(…, true) | 0,35–0,37 ms | 1 | geen |
RESET app.tenant, dan commit | 0,61–0,62 ms | 1 | geen |
RESET ALL, dan commit | 0,61–0,65 ms | 1 | geen |
DISCARD ALL | fout bij taak 7 | 1 | — |
DISCARD ALL, prepare_threshold=None | 0,56–0,57 ms | 1 | geen |
RESET app.tenant zonder commit | 8,9 ms | 301 voor 300 taken | geen |
Twee van deze regels zijn vallen.
DISCARD ALL breekt de prepared statements van psycopg. psycopg bereidt een query automatisch voor zodra hij vaker dan prepare_threshold keer op een verbinding is uitgevoerd. DISCARD ALL gooit op de server alle prepared statements weg, psycopg weet dat niet, en de volgende uitvoering faalt: prepared statement "_pg3_0" does not exist, bij taak 7 in beide reeksen. Met voorbereiden uit (prepare_threshold=None) werkt het wel. Onze functie zette de verbinding voor de DISCARD ALL even in autocommit, omdat die opdracht niet binnen een transactieblok kan draaien.
Opschonen zonder commit kost ongemerkt een verbinding per taak. Volgens de documentatie van de pool moet de verbinding ‘idle’ achterblijven, “otherwise it is discarded”. Zonder autocommit opent RESET app.tenant een transactie, dus gooide de pool na elke taak de verbinding weg en opende een nieuwe: 301 verbindingen voor 300 taken en 8,9 ms per taak in plaats van 0,6. Het ziet er goed uit — er blijft geen context achter, omdat er geen verbinding achterblijft — en het enige spoor is een waarschuwing in het log.
De goedkoopste juiste optie was helemaal niet opschonen en de klant lokaal zetten: 0,35 ms tegen 0,33 voor de lekkende versie.
Waarom wordt een falende taak nooit geteld?
Omdat de teller in de transactie zit die faalt. De meest gebruikte wachtrijopzet claimt, werkt en rondt af in één transactie:
begin;
select id, tenant_id from jobs
where status = 'queued' order by id limit 1
for update skip locked;
-- klant zetten, werk doen
update jobs set status = 'done' where id = $1;
commit;
Dat is aantrekkelijk omdat de rijvergrendeling de claim is: sterft de worker, dan verdwijnt de vergrendeling mee en is de taak weer vrij. Maar als het werk een fout geeft, draait de rollback alles in de transactie terug, ook een attempts = attempts + 1 die je daar neerzet. We zetten één taak klaar die altijd faalt (select 1/0) met een gewone taak erachter, en lieten één worker tien keer een taak ophalen:
| Opzet | Gifpiltaak na 10 keer ophalen | Volgende taak |
|---|---|---|
| één transactie, geen foutafhandeling | 10 keer opgepakt, queued, attempts 0 | nooit gedraaid |
| één transactie, afhandeling telt in een nieuwe transactie | dead na 3, fout vastgelegd | gedraaid bij ophaalbeurt 4 |
| claim in een eigen transactie, daarna het werk | dead na 3, fout vastgelegd | gedraaid bij ophaalbeurt 4 |
Zonder foutafhandeling stond de wachtrij stil: de taak die altijd faalt is ook de oudste, dus order by id geeft hem elke keer opnieuw uit, en de taak erachter wachtte. Zelfs de rij in job_runs die de gifpil bij de start schreef werd teruggedraaid, zodat niets in de database liet zien dat hij ooit had gedraaid. Met een foutafhandeling die een nieuwe transactie opent om de poging te tellen, ging de taak na drie keer naar dead en liep de wachtrij door.
Wat gebeurt er als het workerproces halverwege een taak sterft?
De foutafhandeling draait alleen als de worker nog leeft. We vervingen de gifpil door een taak die midden in het werk het Python-proces beëindigt (os._exit(1)), en startten na elke crash een nieuwe worker — meteen in de opzet met één transactie, die geen lease heeft, en 1,1 seconde later in de opzet met een lease:
| Opzet | Na 6 keer een worker starten | Volgende taak |
|---|---|---|
| één transactie, met foutafhandeling | 6 keer gecrasht op dezelfde taak; queued, attempts 0 | nooit gedraaid |
| claim met een lease van 1 seconde, daarna het werk | attempts 1, 2, 3, daarna overgeslagen; running, attempts 3 | gedraaid bij start 4 |
In de opzet met één transactie draaide PostgreSQL de transactie van de dode worker terug, dus stond de taak weer op queued met attempts 0, en de volgende worker pakte hem op en stierf ook — elke worker, elke keer. Een taak die het proces laat crashen in plaats van een fout te geven, wordt in deze opzet nooit geteld; een worker die wordt afgeschoten omdat het geheugen op is, zou de foutafhandeling op dezelfde manier overslaan.
De tweede opzet commit eerst de claim, in een eigen transactie:
update jobs
set status = 'running', attempts = attempts + 1,
locked_until = clock_timestamp() + interval '1 second'
where id = (select id from jobs
where (status = 'queued'
or (status = 'running' and locked_until < clock_timestamp()))
and attempts < max_attempts
order by id limit 1
for update skip locked)
returning id, tenant_id, attempts;
De poging wordt geteld voordat het werk begint, dus een crash kan hem niet terugdraaien. Na een crash is de taak onzichtbaar tot de lease verloopt, en wordt dan opnieuw geclaimd. Na drie crashes kwam hij niet meer in aanmerking en liep de wachtrij door. Maar hij bleef running staan, met attempts 3 en een verlopen lease — niets in deze query zet hem op dood. Daar is een opruimquery voor nodig, bijvoorbeeld update jobs set status = 'dead' where status = 'running' and locked_until < now() and attempts >= max_attempts — een voorstel; in deze meting hebben we hem niet gedraaid.
Wat als een taak langer duurt dan zijn lease?
Dan neemt een tweede worker hem over terwijl de eerste nog bezig is. Lease 2 seconden, een taak van 3 seconden, een tweede worker die elke 100 ms kijkt:
| Variant | Uitvoeringen | Tweede worker nam hem over na | Databasewijzigingen behouden |
|---|---|---|---|
| geen fencing | 2 | 2,02 s | van beide |
afronden alleen where attempts = <geclaimde poging> | 2 | 2,03 s | alleen van de tweede; eerste teruggedraaid |
| heartbeat verlengt de lease elke 0,5 s | 1 | — | één uitvoering |
Fencing — de taak alleen afronden als de rij nog het pogingnummer draagt dat deze worker claimde — deed wat het belooft: de update van de eerste worker raakte geen rij, die draaide zijn transactie terug, en zijn rij in job_runs verdween mee. Maar beide workers deden drie seconden werk. Fencing houdt de schrijfacties van een late worker uit de database; een mail die hij verstuurde of een API die hij aanriep, is twee keer gebeurd. Alleen de heartbeat, een aparte verbinding die locked_until elke halve seconde verlengt zolang de taak loopt (en alleen voor de poging die hij claimde), voorkwam de tweede uitvoering. Een lease is een gok naar de langste taak; een heartbeat vervangt die gok, maar niet de fence. Een worker die vastloopt maar blijft leven, blijft heartbeats sturen en zijn taak wordt nooit overgenomen; naast een heartbeat hoort dus een maximale looptijd, en voor een heartbeat die te laat komt blijft de fence de laatste verdediging.
Wat kost een taak die zijn transactie openhoudt?
Een trage taak kan een transactie openhouden zolang hij loopt, en een open transactie kan VACUUM tegenhouden bij het opruimen van rijversies die daarna dood zijn geraakt. We lieten één taak van 20 seconden op vijf manieren lopen terwijl een tweede worker 5.000 korte taken afhandelde, en draaiden daarna VACUUM (VERBOSE) jobs:
| Tijdens de 5.000 korte taken liep één taak van 20 s… | Dode rijversies die VACUUM niet kon opruimen | Leeftijd van de oudste xmin, in transacties |
|---|---|---|
| in zijn claimtransactie (opzet met één transactie) | 5.000 | 5.001 |
| na een gecommitte lease-claim, in een werktransactie die eerst één rij schreef | 5.000 | 5.001 |
| na een gecommitte lease-claim, in een werktransactie die alleen las | 0 | 0 |
| na een gecommitte lease-claim, buiten elke transactie | 0 | 0 |
| geen lange taak | 0 | 0 |
Wat VACUUM tegenhield was niet de lease of het ontbreken ervan, maar een lange transactie die iets had geschreven — en SELECT … FOR UPDATE telt mee, want een rij vergrendelen is ernaar schrijven. Een transactie die alleen las, onder het standaard READ COMMITTED en stil tussen twee opdrachten, hield hier niets tegen. De opzet met een lease voorkomt dit dus alleen als het lange deel van het werk buiten een transactie loopt, of in korte.
Twintig seconden en 5.000 taken is weinig, en in de doorvoer was het hier niet te zien (6,6 s tegen 5,5–5,6 s). Dus lieten we het langer lopen: 100.000 klaargezette taken, één worker die ze één voor één ophaalde met de kortst mogelijke ophaalquery (delete … where id = (select … for update skip locked) returning id), en de mediane ophaaltijd per blok van 10.000 taken terwijl een andere sessie een transactie openhield. De laatste drie regels komen uit reeksen van 60.000 taken:
| Terwijl de worker ophaalde, deed een andere sessie… | Taken 1–10.000 | 30.001–40.000 | 50.001–60.000 | 90.001–100.000 |
|---|---|---|---|---|
| niets | 0,65 ms | 0,70 ms | 0,67 ms | 0,71 ms |
| een transactie openhouden die een transactie-ID had | 1,06 ms | 3,44 ms | 5,02 ms | 8,32 ms |
een REPEATABLE READ-transactie openhouden die alleen had gelezen | 1,04 ms | 3,25 ms | 4,71 ms | niet gemeten |
een READ COMMITTED-transactie openhouden die alleen had gelezen, stil | 0,64 ms | 0,68 ms | 0,64 ms | niet gemeten |
| een transactie met ID openhouden, beëindigd na 30.000 taken | 1,06 ms | 0,69 ms | 0,72 ms | niet gemeten |
Met de horizon vastgehouden was elk blok van 10.000 trager dan het vorige, in een rechte lijn: na 100.000 taken kostte ophalen 8,3 ms in plaats van 0,7, voor een wachtrij die toen bijna leeg was. EXPLAIN (ANALYZE, BUFFERS) op de ophaalquery na 50.000 taken laat zien waarom: 654 buffers en 4,1 ms met de transactie open, 42 buffers en 0,05 ms zonder. De indexscan moet nog langs de vermeldingen van elke al opgehaalde taak, omdat PostgreSQL die rijen niet als dood mag behandelen zolang er een oudere transactie openstaat. De opzet met status = 'done' in plaats van delete en een gedeeltelijke index op klaarstaande taken gaf dezelfde lijn, eindigend op 8,34 ms. Een REPEATABLE READ-transactie die alleen leest — de vorm van een lang rapport of een pg_dump — deed hetzelfde als een met een transactie-ID, omdat hij zijn snapshot tussen opdrachten vasthoudt. Na het einde van de transactie zat de mediaan van het volgende blok weer op 0,69 ms.
We hebben alleen de tabel jobs gemeten; de horizon van VACUUM geldt voor de hele database, dus een taak die schrijft en daarna een uur werkt, zou het opruimen tegenhouden van elke tabel die in dat uur veranderde. PostgreSQL kan met idle_in_transaction_session_timeout een sessie beëindigen die stil in een transactie zit, maar die staat standaard uit. PlanetScale mat hetzelfde mechanisme op grotere schaal, met analysequery’s als de lange transacties, in Keeping a Postgres queue healthy.
Hoort de takentabel onder row-level security?
Niet onder hetzelfde beleid als de gegevens van de klant. Met het klantbeleid ook op jobs zag een worker zonder klantcontext 0 van de 100 taken en gaf de claimquery niets terug — geen fout, een schijnbaar lege wachtrij. Een worker die alle klanten bedient, moet elke taak kunnen zien voordat hij weet welke klant hij moet worden. Houd de takentabel buiten het klantbeleid, of geef de worker voor de claim een rol die het omzeilt, en draai het werk als de klant met set_config(…, true).
Wat wij zouden bouwen
| Probleem | Eén transactie | Claim met een lease |
|---|---|---|
| klantcontext na de taak | set_config(…, true) in de transactie van de taak | hetzelfde |
| falende taak geteld | alleen met afhandeling in een nieuwe transactie | ja, bij de claim |
| crashende taak geteld | nee — laat elke worker crashen | ja; ruim running-taken na hun laatste poging op naar dead |
| taak duurt langer dan verwacht | houdt een schrijvende transactie open, houdt VACUUM tegen; ophalen wordt trager met elke taak die zolang wordt opgehaald (0,7 → 8,3 ms na 100.000) | loopt twee keer, tenzij een heartbeat de lease verlengt; fence het afronden; houd lang werk uit één schrijvende transactie — en lange REPEATABLE READ-rapporten of een pg_dump vertragen de wachtrij in beide opzetten |
| taak van een dode worker weer vrij | meteen, als het proces afsluit | na de lease |
Voor taken van een paar honderd milliseconden die alleen in de database schrijven, is één transactie met foutafhandeling eenvoudiger en goed genoeg — met twee grenzen die we maten: een mislukte taak staat meteen weer vooraan, dus pogingen volgen elkaar direct op, en een taak die het proces laat sterven, laat nog steeds elke worker sterven. Voor alles wat de buitenwereld aanroept, lang duurt of het proces kan laten sterven: claim met een lease, tel bij de claim, stuur een heartbeat en fence het afronden. In beide opzetten wordt de klant lokaal gezet, in de transactie, elke keer.
Wat we niet hebben gemeten
Een workermachine die van het netwerk verdwijnt in plaats van af te sluiten: PostgreSQL merkt dat pas als de TCP-keepalives het opgeven, en tcp_keepalives_idle staat standaard op de waarde van het besturingssysteem; tot dan blijft een taak in de opzet met één transactie vergrendeld. Wachttijd tussen pogingen, meer dan twee workers, andere drivers en poolers, en eerlijkheid tussen klanten — dat staat in takenwachtrij per klant in PostgreSQL: de luidruchtige buur. De tijden komen van één machine en zijn om te vergelijken, niet om capaciteit op te plannen.
Dit zijn de delen van een multi-tenant systeem die vroeg goedkoop te beslissen zijn en later duur om te veranderen; de rest van die lijst staat in multi-tenant SaaS op PostgreSQL: zes beslissingen.
Bronnen: de documentatie van psycopg 3 over de verbindingspool (de reset-functie en het contextgedrag van connection()) en over prepared statements (prepare_threshold), en de documentatie van PostgreSQL 17 over verbindingsinstellingen (tcp_keepalives_idle) en standaardinstellingen voor clients (idle_in_transaction_session_timeout), alle gelezen op 3 oktober 2026, en Keeping a Postgres queue healthy van Simeon Griggs (PlanetScale, 10 april 2026), gelezen op 4 oktober 2026. Alle metingen zijn van onszelf, op PostgreSQL 17.10, op 3 en 4 oktober 2026.