2026-05-21
Een loader die 6 keer OOM ging in 52 seconden laten draaien
Een Parquet-naar-Postgres rewrite: van batch naar streaming execution, van Python naar Rust.
Het probleem
Ik werkte recent aan een data engineering probleem: opgeschoonde Parquet-bestanden stonden in object storage en moesten periodiek naar Postgres, voor een downstream MLE-proces.
De pijn zat in het laden van grote bestanden. Een tabel had ongeveer 5,17 miljoen rijen. De Python-versie draaide in een serverless functie met 4GB geheugen en ging 6 van de 6 pogingen OOM. Deploys waren ook traag: de Python data stack nam pyarrow, pandas, database drivers en andere dependencies mee. Een lokale upload kon tientallen minuten duren.
Er lagen een paar opties op tafel:
| Optie | Lost OOM op? | Lost trage deploys op? | Trade-off |
|---|---|---|---|
| Van 4GB naar 8GB | Misschien tijdelijk | Nee | Snelst, maar schuift het probleem door |
| Python streaming maken | Ja | Nee | Kleinere wijziging, nog steeds zware dependencies |
| Rust rewrite | Ja | Ja | Meer werk, maar pakt runtime en deployment samen aan |
Ik koos Rust niet omdat Python dit niet kan, maar omdat deze functie op twee plekken vastliep: geheugen en deployment. Een van de twee oplossen zou iteratie nog steeds traag houden.
Na de rewrite zagen de cijfers er zo uit:
| Metric | Oude Python-versie | Nieuwe Rust-versie |
|---|---|---|
| Grote tabel | 5.174.400 rijen | 5.174.400 rijen |
| Resultaat | 6 pogingen, allemaal 4GB OOM | Een run, ongeveer 52 seconden |
| Deploy zip | Ongeveer 77,8MB | Ongeveer 2,5MB |
| Lokale upload | Ongeveer 40 minuten | Ongeveer 3,5 seconden |
| Runtime dependencies | Python data stack + packages | Een statische binary |
Deze cijfers klinken al snel als “Rust is sneller dan Python.” Ik splits het liever in twee veranderingen:
- Execution model: van “de hele batch in memory materialiseren” naar “lezen en schrijven als stream.”
- Deployment unit: van een zware Python data stack naar een kleine statische binary.
Het eerste loste OOM op. Het tweede loste trage deploys en zware dependencies op. Ze gebeurden alleen in dezelfde rewrite.
Waarom de oude pipeline stukliep
De oude versie zag er ongeveer zo uit:
tables = [pq.read_table(file) for file in files]
df = pa.concat_tables(tables).to_pandas()
cursor.copy_from(StringIO(df.to_csv()), table_name)
Het probleem: dit leest niet een beetje en schrijft niet een beetje. Het leest alle Parquet-bestanden naar Arrow tables, combineert ze tot een grote tabel, zet die om naar een pandas DataFrame, maakt daar een grote CSV-string van, en stuurt die pas daarna naar Postgres.
Een beeld:
Je moet een vrachtwagen zand van A naar B verplaatsen. De oude aanpak is geen lopende band. Eerst kiept hij het zand in de woonkamer, daarna in een andere emmer, daarna op een plastic zeil, en pas dan wordt het hele zeil naar B gesleept.
Elke stap maakt een nieuwe grote kopie:
- Arrow-data gedecodeerd uit Parquet.
- Een gecombineerde Arrow table.
- Een pandas DataFrame.
- Een CSV-string.
5,17 miljoen rijen hoeft niet enorm te zijn. Het probleem is dat meerdere vormen van dezelfde data tegelijk bestaan. 4GB is geen magazijn. Het is een kleine doos. Stop er meerdere grote tabellen in en hij gaat OOM.
Batch vs streaming execution
Bij “streaming” denken mensen snel dat een batch job een stream-processing systeem is geworden.
Dat is hier niet zo.
Dit blijft een batch job. De input is een vaste set Parquet-bestanden. De output is een Postgres-tabel. Het is geen eindeloze Kafka-achtige stream, en downstream hoeft niet per event te worden bijgewerkt.
Houd de batchgrens. Gebruik streaming execution om peak memory te verlagen.
Batch processing en streaming execution zijn geen tegenpolen. Ze beantwoorden verschillende vragen.
Batch processing beantwoordt: wat is de business commit unit?
Bijvoorbeeld: “de target table wordt pas bijgewerkt als alle bestanden in deze batch slagen.” Dat is de batchgrens.
Streaming execution beantwoordt: hoe verplaatst het programma data intern?
Bijvoorbeeld: “houd een kleine chunk vast, converteer hem, schrijf hem weg, ga door.” Dat is het execution model.
De nieuwe pipeline is grofweg:
Parquet-bestanden in object storage
-> decodeer naar Arrow RecordBatches
-> zet elke batch om naar CSV bytes
-> stuur bytes door een bounded queue
-> stream naar Postgres COPY FROM STDIN
Een paar termen:
Parquet is een columnar storage format. Waarden uit dezelfde kolom staan bij elkaar, wat compressie efficient maakt en scans op een subset van kolommen versnelt.
Arrow is een standaard in-memory tabelformaat. Veel data tools kunnen het lezen en schrijven, waardoor je minder type-conversies nodig hebt.
RecordBatch is een kleine chunk Arrow-data. Het belangrijke woord is “klein”: het programma houdt een stuk vast, niet de hele tabel.
Streaming betekent dat data door het programma stroomt. Het programma houdt het huidige stuk vast, verwerkt het, en laat het weer los.
Backpressure betekent dat de upstream kant vertraagt als downstream trager is. De queue in het midden heeft een limiet, dus producers kunnen niet onbeperkt data opstapelen.
Postgres COPY is het bulk-importpad van Postgres. Het is veel sneller dan rij-voor-rij INSERT, omdat veel SQL parsing, round trips en protocol overhead per rij wegvallen. Voor bulk loads is COPY meestal de eerste route om te overwegen.
De wijziging zit niet in een slimme regel code. Het is een andere vorm:
Oud: maak van alles een gigantisch object, schrijf daarna. Nieuw: houd data in beweging; voer downstream op de snelheid waarop het kan consumeren.
Maakt streaming retries moeilijker?
Ja. Je moet de grens zorgvuldiger ontwerpen.
Batch heeft een duidelijke grens: bereid de hele batch voor, schrijf hem daarna. Als het faalt, draai je de batch opnieuw. De keerzijde is duidelijk: als “voorbereiden” betekent dat de hele batch in memory staat, wordt peak memory hoog.
Streaming houdt memory stabiel, maar fouten kunnen halverwege gebeuren: er is al data gelezen, er is al data geschreven, en dan valt het netwerk weg, time-out de functie, of sterft de databaseverbinding. Zonder consistentiegrens kun je een halve tabel achterlaten, duplicaten schrijven, of downstream incomplete data laten lezen.
Dus streaming is niet alleen “lezen terwijl je schrijft.” Het vraagt een paar keuzes:
- Transaction boundary. Deze job gebruikt
BEGIN -> TRUNCATE -> COPY -> COMMIT. Als COPY halverwege faalt, rolt de transaction terug en wordt de oude tabel niet vervangen door een gedeeltelijk resultaat. - Idempotent entry point. Dezelfde tabel over dezelfde input opnieuw draaien moet hetzelfde resultaat geven. Dan zijn retries veilig.
- Expliciete failure points. Te grote bestanden, schema mismatches, netwerkfouten en COPY-fouten moeten hard falen, niet stil worden overgeslagen.
- Staging table waar nodig. In complexere gevallen kun je eerst naar een staging table COPYen, row counts en kwaliteit valideren, en daarna atomisch naar de target table wisselen.
- Maak van een eindige batch geen oneindig stream-systeem. Dit is bounded streaming: eindige input, eindige output, batch commit boundary.
Daarom zeg ik liever “streaming execution” dan alleen “stream processing.” Het voorkomt dat een bounded batch job wordt verward met een real-time stream systeem.
Python vs Rust
Een andere logische vraag: kan Python dan niet streamen?
Jawel.
Als alleen OOM het probleem is, heeft Python een prima oplossing: gebruik een pyarrow batch reader om Parquet in chunks te lezen, en gebruik psycopg2 of asyncpg COPY om chunks naar Postgres te schrijven. Dan vermijd je dat to_pandas() en to_csv() de hele tabel tegelijk in memory zetten.
De conclusie is dus niet “Python is slecht voor data engineering.” Python is hier juist sterk in.
Het punt is dat deze functie meer had dan OOM. Het deploy package was groot, uploads waren traag, en de serverless runtime droeg zware dependencies. Python streaming kan peak memory verlagen, maar haalt de deployment cost van pyarrow + pandas niet automatisch weg.
Rust hielp omdat het twee dingen tegelijk oploste:
- Het execution model werd streaming.
- De deployment unit werd een kleine binary.
Anders gezegd:
- Als alleen peak memory pijn doet, is Python streaming waarschijnlijk de goedkopere fix.
- Als dependency size, cold start, deploy speed en runtime boundaries ook pijn doen, wordt een compiled language aantrekkelijker.
Haal “batch vs stream” en “Python vs Rust” niet door elkaar. Het eerste is een data execution model. Het tweede is een runtime en delivery model.
Wat Rust toevoegde
De waarde van Rust was in dit geval concreet.
Eerst: de deployment unit werd klein. De Python-versie moest zware data dependencies meenemen. De Rust-versie compileert naar een Linux binary. In deze validatie ging de deployment zip van ongeveer 77,8MB naar ongeveer 2,5MB, de lokale upload van ongeveer 40 minuten naar een paar seconden, en de uiteindelijke bootstrap binary was ongeveer 6,5MB.
Ten tweede: memory boundaries werden duidelijker. Rust’s ownership model dwingt je om te vragen wie de data bezit, wanneer die wordt vrijgegeven, en of die wordt gekopieerd. In sommige business code voelt dat als frictie. In data movement code helpt het juist om verborgen grote kopieen zichtbaar te maken.
Ten derde: het async ecosysteem past bij deze pipeline. Object storage lezen, data converteren en naar Postgres schrijven is een producer-consumer model met zowel IO als CPU-werk. tokio, channels en streaming sinks drukken die vorm direct uit.
De snelheid kwam niet van het woord “Rust.” Die kwam van streaming execution.
Schrijf Rust als “alles lezen, alles concateneren, alles schrijven” en het kan ook OOM gaan. Schrijf Python als streaming en het lost een groot deel van dit probleem op. Rust paste hier omdat het ook de deployment unit en runtime dependencies oploste.
Een paar valkuilen
Ik ga niet diep in op de internals, maar een paar valkuilen zijn het onthouden waard.
Een: neem niet aan dat een cloud function invoke lijkt op een normale HTTP client.
De custom runtime request zette geen Content-Type: application/json. De JSON extractor van het framework gaf daarom 415 terug. Raw bytes lezen en JSON handmatig parsen loste het op.
Serverless platformen hebben vaak hun eigen runtime protocol. Framework defaults passen daar niet altijd op.
Twee: zelf ondertekende cloud API requests hebben ground-truth tests nodig.
Ik wilde niet leunen op een unofficial OSS Rust SDK, dus implementeerde ik OSS V4 signing direct. Zulke code kan er goed uitzien en toch in productie 403 teruggeven. De veiligere route was signing samples genereren met een volwassen Python SDK, en Rust unit tests byte voor byte laten matchen.
Als je zelf een protocol implementeert, anker het aan een vertrouwde implementatie.
Drie: deployment experience is onderdeel van architectuur.
Teams praten vaak alleen over runtime performance. Maar als elke deploy tientallen minuten duurt, gaan engineers minder vaak deployen, minder vaak valideren, en minder kleine wijzigingen maken. Het systeem wordt brozer.
Een kleiner deployment package bespaart niet alleen tijd. Het maakt frequent valideren weer goedkoop.
Wat ik meeneem
De les is niet “herschrijf Python in Rust.” Het zijn een paar checks.
Eerst: bij OOM, teken de vorm van de data in memory. Kijk niet alleen naar input size. Kijk naar hoeveel tussenkopieen er bestaan.
Ten tweede: voor data movement jobs, kies streaming execution als de business semantics het toelaten. Gebruik batch size en backpressure om memory te begrenzen.
Ten derde: houd de batch commit boundary. Streaming execution betekent niet dat je transactions, consistentie of idempotency opgeeft. Voor eindige input is de comfortabele vorm vaak een bounded streaming batch.
Ten vierde: bij Postgres bulk loads, denk eerst aan COPY, niet aan rij-voor-rij INSERT. INSERT is voor transactionele writes. COPY is voor tabellen laden.
Ten vijfde: kies de taal samen met de deployment omgeving. Als alleen peak memory pijn doet, kan Python streaming genoeg zijn. Als dependency size, cold start, deploy speed en runtime boundaries ook pijn doen, wordt een compiled language interessanter.
Ten zesde: optimaliseren gaat niet alleen om “sneller.” Het gaat om “beter verklaarbaar.” Een job in 52 seconden is fijn. Belangrijker: je weet waarom hij niet ontploft, waar hij trager kan worden, waar backpressure hoort, en waar retries veilig zijn.
Dat wil ik van deze fix onthouden.
Gedachten hierover? Discussieer met mijn agent, of stuur me een bericht.