AI agents
Python 3.14 GC: hoe een Cloud Run agent zes uur stilviel
Donderdag zag een 26-koppige HR-tech leverancier in Breda hun onboarding-agent zes uur stilvallen na een routinematige Python 3.14 deploy. De oorzaak: de nieuwe incremental GC.

Om 09:47 op een donderdag in juni stuurde een operations lead bij een 26-koppige HR-tech leverancier in Breda me een berichtje op Signal. Hun onboarding-agent, een Python 3.14 worker op Cloud Run, was zo'n uur eerder gestopt met het afronden van jobs. Drieëntwintig nieuwe medewerkers zaten vast op stap drie van een flow van zeven stappen. Hun CS-team was de excuusmail al aan het opstellen.
Toen we ophingen, was de ruwe vorm duidelijk. Om 15:30 stond een fix in productie. De oorzaak was de nieuwe incrementele garbage collector van Python 3.14 die state opruimde waarvan een langlevende async generator afhing. De fix was twee regels.
Hieronder de walkthrough.
Het systeem dat brak
De leverancier bouwt onboarding-software voor Europese uitzendbureaus. Hun onboarding-agent is een Python worker op Cloud Run die het heen-en-weer regelt tussen een nieuwe medewerker, hun manager en de payroll provider van het bureau. Elke job draait tussen de vijftien en negentig minuten omdat hij wacht tot echte mensen op mails reageren en documenten ondertekenen.
De worker is geschreven als een langlevende async generator die yieldt tussen stappen. Komt er een webhook terug (handtekening ontvangen, payroll geregistreerd, ID-check geslaagd), dan hervatten we de generator vanaf waar hij voor het laatst yieldde. Zo blijft de orkestratiecode lineair en leesbaar. De frame houdt alle state vast waar we om geven terwijl we wachten. We gebruiken dit patroon veel. Het is een redelijke default voor agents die met mensen praten.
De deploy die er onschuldig uitzag
Ze hadden dinsdagavond een routine-deploy gedaan. De diff was klein. Een Pydantic-bump, een copy change in de manager-approval-mail en een base-image rebuild die de runtime van Python 3.13 naar Python 3.14 verplaatste. Niets in de diff raakte de agent loop. Tests slaagden. Staging draaide een complete onboarding-cyclus van begin tot eind. Ze mergeden.
Ongeveer zesendertig uur lang zag productie er prima uit. Cloud Run schaalde de worker automatisch tussen twee en vier instances. Jobs werden afgerond in het gebruikelijke tempo. Toen, donderdagochtend rond 08:30 Amsterdamse tijd, begonnen jobs stilletjes voortgang te laten vallen. De worker pakte een job van de queue, draaide een paar seconden, en daarna: of een AttributeError op een coroutine, of stilletjes terugspoelen naar het begin van de flow. De inbox van de medewerker werd niet meer achterna gezeten voor handtekeningen. Er bereikte geen enkele error de klant.
Het symptoom dat het weggaf
Wat ons naar de GC wees, was de timing. De eerste failures vielen bijna exact samen met het moment dat de worker-instances negentig minuten uptime overschreden. De min-instance flag van Cloud Run stond op twee, dus twee van de vier warme workers waren lang genoeg in leven om een major collection te raken. De andere twee, die recenter waren gerecycled, waren gezond.
We bevestigden het met een bot instrument. We zetten max-instance-age van Cloud Run tijdelijk op vijftien minuten. De failure rate zakte naar nul binnen één autoscale-cyclus. Dat was het rokende pistool. Iets aan langlevende instances was fout, en het was fout op een manier die de runtime, niet onze code, bepaalde.
De incrementele collector van Python 3.14
Python 3.14 levert een nieuwe incrementele garbage collector. In plaats van een stop-the-world walk over de oude generatie in één keer, verwerkt de tracer de oude generatie in kleine stukken verspreid over veel collectie-cycli. De motivatie is helder. Een monolithische collectie van de oude generatie kan een proces honderden milliseconden pauzeren op een grote heap, wat genadeloos is voor latency-gevoelige services. De increments lossen dat op.
Ze veranderen ook een subtiele invariant. Met de niet-incrementele tracer werd elke referentie in de oude generatie ofwel volledig in één pass bezocht, ofwel helemaal niet. Met de incrementele tracer wordt de heap waargenomen in een gedeeltelijk getraceerde staat, verspreid over veel korte scans. Voor de meeste workloads is dat prima. Het CPython-team heeft zorgvuldig werk verzet om de tracer correct te houden onder mutatie.
Voor een worker waarvan de request lifetime in minuten gemeten wordt (omdat hij op een mens wacht), en die state vasthoudt via een async generator-frame, is de gedeeltelijk-getraceerde wereld lastiger te testen. Onze worker raakte een geval waarin module-level helpers die werden gerefereerd vanuit een gepauzeerde generator-frame als unreachable werden waargenomen, opgeruimd, en daarna bij het hervatten gedereferenced. Het resultaat was, afhankelijk van welk object werd geraakt, ofwel een AttributeError, ofwel een stille re-bind naar een gerecycled object met het verkeerde type. We zagen beide, op verschillende jobs, op dezelfde worker, binnen enkele minuten van elkaar.
Dit is het soort bug dat niet opduikt in een staging-run van vijftien minuten. Staging leeft nooit lang genoeg om een major collection te raken.
De fix van twee regels
De fix die we nu in elke langlopende agent-worker bakken, is dit:
import gc
gc.freeze()
gc.collect()
Drie regels, technisch gezien, als je de import meetelt. Je zet die onderaan de startup-module van je worker, nadat elke import en elke module-level initialisatie heeft plaatsgevonden. gc.freeze() verplaatst elk momenteel-getraceerd object naar een permanente generatie waar de collector nooit meer langs gaat. De daaropvolgende gc.collect() triggert één schone pass voordat de requests binnenkomen, zodat je daar later niet voor betaalt.
Meer is het niet. De onboarding-agent draait sinds dit artikel negen dagen schoon. Max-instance-age van Cloud Run staat weer op vierentwintig uur.
Waarom het bevriezen van de heap werkt
De heap bevriezen aan het eind van startup is een oude truc. Hij dook eerst op in productie voor fork-gebaseerde webservers, waar de Python heap vlak voor fork ieders working set is en je wil dat hij na fork in shared pages leeft. Instagram documenteerde hun versie hiervan op uWSGI om hun CPython workers te behoeden voor copy-on-write churn.
Voor een agent-worker laat de truc zich netjes vertalen. Alles wat je bij startup laadt (je prompt templates, je tool schemas, je retry policies, je client objects voor de LLM-gateway en, in dit geval, de payroll API) is precies het soort langlevende state dat nooit door een incrementele tracer bezocht zou moeten worden. Eenmaal bevroren heeft de tracer een veel kleinere working set: per-request data. Dat is de workload waar de GC voor bedoeld is.
Voor een request-overspannende generator heeft de freeze een tweede voordeel. De frame van de generator zelf wordt gealloceerd op het moment dat de generator wordt aangemaakt, dus die leeft in de jonge generatie en wordt niet bevroren. Dat is prima. De bug die we raakten, was niet de frame, maar de module-level helpers waar de frame naar verwees. Die bevriezen haalt ze uit het pad van de tracer en het resume-pad van de generator vindt geen gaten meer.
Roep gc.freeze() niet aan als je worker prompt templates of tool registries hot-reloadt tijdens runtime. Je pint de oude versies dan voor altijd in het geheugen vast en lekt geruisloos.
Waar je het neerzet
Drie vuistregels na het uitrollen hiervan over onze hele worker-vloot.
Eén: bevries onderaan startup, niet bovenaan. Je wil dat elke import klaar is. Je wil elke dataclass gehydrateerd hebben. Je wil dat je tool registry gevuld is. Het punt van bevriezen is een streep trekken tussen 'dit is permanent' en 'dit is per request', en die streep zit aan het eind van startup, niet aan het begin.
Twee: bevries niet binnen een unit-test process. Bevroren objecten overleven over tests heen in dezelfde interpreter, en pytest gaat dan leaks tonen die niet echt zijn. Zet de aanroep achter een environment variable, of roep hem alleen aan in je productie-entrypoint module.
Drie: als je worker iets hot-reloadt tijdens runtime (prompts, tool definitions, feature flags die bij boot worden opgehaald), dan is bevriezen de verkeerde vorm. Je pint een oude versie dan voor altijd vast. Voor zulke workers: zet de GC uit op het hot path en draai gc.collect() tussen jobs door, of pin je runtime terug naar Python 3.13 totdat de incrementele tracer is uitgekristalliseerd. Het CPython-team itereert hierop; verwacht wijzigingen in 3.14.1 en 3.14.2.
Het bredere patroon voor agent workers
De reden dat dit specifiek voor agent workers pijn doet, is dat we state in leven houden over menselijke tijd. Een web request leeft milliseconden. Een Celery-job leeft seconden. Een agent-orkestratie job leeft minuten of uren, omdat hij wacht tot een mens iets doet. Alles in je runtime dat aanneemt dat een request snel klaar is, gaat je vroeg of laat opbreken. De nieuwe incrementele GC is daar één van. Het wordt niet de laatste.
Draai je agents op Cloud Run, Cloud Functions, Lambda met provisioned concurrency, of welk ander serverless platform dan ook dat een worker warm houdt: audit je runtime op invarianten die afhangen van de leeftijd van de instance. We hebben hetzelfde patroon gezien bij: OpenTelemetry batch span exporters die spans laten vallen na een vaste buffer-leeftijd, async HTTP clients die hun connection pool sluiten na dertig minuten idle, en Cloud SQL pools die stilletjes achter je rug om recyclen. Niets daarvan zijn bugs in het platform. Het zijn mismatches tussen de aanname 'kortlopende request' en de realiteit 'langlopende job'.
De goedkope structurele fix is een instance-age heartbeat. Emit elke minuut de leeftijd van je worker. Page wanneer hij iets overschrijdt waar je daadwerkelijk tegenaan getest hebt. Voor de leverancier in Breda is dat getal nu negentig minuten, want dat is de langste staging-run die ze draaien.
Wat we in onze worker-template hebben aangepast
De onboarding-agent die kapotging, is één van veertien agents die we live in productie hebben staan. Na dit incident hebben we drie dingen aan onze worker-template toegevoegd, op volgorde van belang.
Eén: het duo gc.freeze(); gc.collect() onderaan de startup-module, achter een ABN_FREEZE_GC=1 environment variable, zodat test runs nog steeds een normale heap zien.
Twee: een instance-age metric die elke zestig seconden naar Cloud Monitoring wordt geëmit. De alert vuurt bij honderdtwintig minuten tijdens kantooruren.
Drie: een smoke job die elke vijf minuten tegen de warme worker draait, het volledige async generator-pad van begin tot eind doorloopt, en de deploy laat falen als een job het verkeerde type teruggeeft uit een hervatte yield. Goedkoop te schrijven, erg luid wanneer hij afgaat.
Niets hiervan is bijzonder slim. Het zijn precies de dingen die je één keer instelt en daarna vergeet. Het zijn ook precies de dingen die je niet schrijft voordat productie je pijn heeft gedaan.
De afsluiting
Toen we de onboarding-agent voor die leverancier in Breda bouwden, hadden we niet voorspeld dat een runtime upgrade zonder code changes vier jaar later in stilte een generator-vormige agent kapot zou maken. We hebben het uiteindelijk opgelost met twee regels en een heartbeat. Bouw je AI-agents die menselijke tijd overspannen, dan is de worker-template de plek waar dit soort weerbaarheid hoort. Het is de goedkoopste plek om hem neer te zetten.
Als je vandaag maar één ding doet: open de startup-module van je agent-worker, voeg onderaan import gc; gc.freeze(); gc.collect() toe, en ship het. Zet daarna een alert op instance-age. De rest is decoratie.
Kern
Voeg gc.freeze() onderaan de startup van je Python 3.14 worker toe. Twee regels voorkomen dat de nieuwe incrementele GC state opruimt waar je langlopende agent van afhangt.
FAQ
Geldt dit ook voor Python 3.13 of eerder?
Nee. De incrementele GC-pass is nieuw in 3.14. Workers op 3.13 en eerder hebben geen last van deze specifieke failure mode, al blijft gc.freeze een waardevol patroon voor elke langlopende worker.
Is dit een bug in Cloud Run?
Nee. Cloud Run is hier de boodschapper. Hetzelfde patroon duikt op bij elk platform dat een Python 3.14 worker lang genoeg warm houdt om een major garbage-collection pass te raken, inclusief Lambda en zelf-gehoste VM's.
Lost CPython het onderliggende probleem op?
Het CPython-team is actief aan het tunen van de incrementele tracer. Verwacht wijzigingen in 3.14.1 en 3.14.2. Tot die er zijn, is gc.freeze plus een instance-age alert het veilige pad.
En specifiek bij async generators?
Zelfde vorm. De bug was niet het generator-object zelf, maar de module-level state waar zijn frame naar verwees. Door die module-level state te bevriezen, stopt de tracer met hem te bezoeken.