AI-codering heeft CI tot een bottleneck gemaakt, dus we hebben onze CI herzien om bij te blijven
Bij Linear hebben we onze CI geoptimaliseerd om de wachttijd per PR en het verbruik van runner-tijd te verminderen. Ondanks dat onze testsuites sinds het begin van het jaar bijna verviervoudigd zijn, hebben we de wachttijd voor pull requests teruggebracht van meer dan 6 minuten naar iets boven de 5 minuten, terwijl we de runner-tijd per test ongeveer halveren.
We hebben de CI op vier hoofdwegen verbeterd:
- Upgraden van infrastructuur en tooling.
- Optimaliseren van de jobs die ander werk blokkeren (gating).
- Verminderen van herhaalde setup.
- Het efficiënter maken van de testuitvoering.
De codebase van Linear bestaat voornamelijk uit TypeScript, maar veel van deze optimalisaties zijn toepasbaar op andere talen en toolchains.
Upgraden van infrastructuur en tooling
Sommige van onze eerste winsten vereisten nauwelijks optimalisatie van de CI zelf. Door onze workloads te verplaatsen van GitHub Actions naar third-party runners met snellere CPU's, betere opslag en een superieure cache-infrastructuur, konden we dezelfde pipeline op snellere machines draaien. In een directe vergelijking draaiden jobs gemiddeld 34% sneller, waarbij sommige workloads, zoals tsc, een verbetering van 52% lieten zien.
Daarnaast leverde het moderniseren van onze toolchain resultaten op. De overstap naar tsgo, de native TypeScript-compiler, verkortte de wekelijkse mediaan van de tsc-check met 73%, wat groot genoeg was om typechecking volledig uit de bottleneck te halen.
Linting zonder typechecker
Linting was een ander vroeg doelwit. Een aantal van onze aangepaste lint-regels was afhankelijk van TypeScript-type-informatie. Dit betekende dat elke lint-run de volledige type-graph moest bouwen voordat de regels konden worden geëvalueerd, waardoor linting een van onze meest geheugenintensieve CI-jobs werd.
We hebben de regels herschreven om statische analyse op de abstract syntax tree (AST) te gebruiken, waardoor functies en guard-patronen konden worden geïdentificeerd zonder type-informatie. Hierdoor kon ESLint TypeScript volledig loslaten, wat de lint-tijd voor de API met 68% en de lint-tijd voor het volledige repository met 55% verminderde. Ook het geheugengebruik daalde aanzienlijk.
Het verwijderen van de afhankelijkheid van type-informatie maakte de latere overstap naar Oxlint veel eenvoudiger, omdat regels die puur op syntax werken eenvoudig te porteren zijn. Oxlint zelf verminderde de runner-minuten die aan linting werden besteed.
Optimalisatie van jobs die ander werk blokkeren
Met snellere infrastructuur en individuele checks keken we naar de CI als een systeem. Onze aandacht ging uit naar de kleine jobs die vóór alle andere taken staan. Elke run begint met het controleren van welke paden een PR heeft geraakt en of deze tests al zijn geslaagd voor dezelfde inputs. We plaatsen deze checks op job-niveau zodat overgeslagen taken geen runner reserveren, maar dit plaatst ze ook direct op het kritieke pad. Geen van de acht API-test-shards kan starten voordat deze checks zijn voltooid, waardoor zelfs kleine vertragingen disproportioneel belangrijk worden.
Alleen ophalen wat elke job nodig heeft
Verschillende workflows beginnen met een change-detection job. Deze jobs checkten voorheen de volledige working tree uit, terwijl ze slechts een klein subset daarvan nodig hadden. We hebben de fetch-depth beperkt, waardoor de traagste van deze gates versnelde van 94 seconden naar 20 seconden. Voor jobs die helemaal geen working tree nodig hadden, hebben we de checkout volledig verwijderd, wat de tijd reduceerde van 27 naar 7 seconden. Voor commit-push en merge-queue events, waarbij we paden moeten diffen, bleek een sparse, blobless checkout met beperkte geschiedenis voldoende, wat nog eens 11 seconden bespaarde.
De mediane duur van de change-detection job daalde van 26 naar 8 seconden; de p90 van 31 naar 12 seconden, en de traagste run van 138 naar 37 seconden.
De checkout veerkrachtiger maken
Na de overstap naar de nieuwe runner-infrastructuur merkten we dat de checkout-tijden (met actions/checkout) langer werden en soms hingen. Omdat de third-party runners buiten het netwerk van GitHub staan, vertrouwen ze op een directe IP-link. De provider sporeerde de freezes terug naar intermitterende degradatie op die link.
Om veerkrachtig te zijn tegen deze netwerkinstabiliteit, hebben we actions/checkout vervangen door een eigen composite action die retries met backoff uitvoert. Daarnaast stellen we GITHTTPLOWSPEEDLIMIT en GITHTTPLOWSPEEDTIME in, zodat een gestagneerde verbinding na ongeveer 30 seconden wordt afgebroken in plaats van te blijven hangen. Ook gebruiken we een checkout-cache, die een persistente git-mirror op een sticky disk bewaart. Het resultaat was aanzienlijk minder runs waarbij een job op het kritieke pad idle bleef wachten op de checkout.
Het kritieke pad minimaliseren
Niet elke job hoefde op het kritieke pad te staan. We schreven cache-markers als onderdeel van de laatste check voor het mergen, wat betekende dat een pull request in de merge-queue kon blijven staan, zelfs nadat de tests waren geslaagd. We hebben deze schrijfactie verplaatst naar een job die start zodra de test-shards klaar zijn, maar die niets anders blokkeert. Dit bespaarde 42 seconden op het merge-pad voor elke API-pull request en merge-queue entry.
Samen hebben deze wijzigingen ongeveer een minuut van de vereiste check voor API-pull requests gehaald bij cache-misses, terwijl het aantal runner-starts is verminderd.
Herhaalde setup verminderen
Vervolgens richtten we ons op de setup-kosten die in elke job worden herhaald, zoals het opstarten van een runner, het installeren van pakketten en het voorzien van build-dependencies. Deze overhead kan ervoor zorgen dat een job die slechts enkele seconden nuttig werk doet, toch minuten aan infrastructuurtijd verbruikt.
Gedeelde dependencies pre-installeren in het CI-image
Ondeze API-test-shards besteedden elke run 7 tot 8 seconden aan het installeren van dezelfde Postgres-client via apt. We hebben dit verplaatst naar een klein CI-base-image dat Node en de client bevat. Later voegden we de benodigde native build-headers toe aan het image om incidentele hangs tijdens de setup te voorkomen.
Alleen dependencies installeren die elke job nodig heeft
De codebase van Linear is een monorepo die wordt beheerd als een pnpm workspace. Onze API-test workflow installeerde de volledige workspace, terwijl alleen het API-pakket en de bijbehorende dependencies nodig waren. Door de installatie te beperken tot ons API-pakket, daalde de pnpm install tijd van 44-73 seconden naar 16-18 seconden. We hebben hetzelfde patroon toegepast op API-gerelateerde jobs.
Niet cachen als herbouwen sneller is
We hebben het cachen van node_modules getest en ontdekten dat herbouwen sneller was. De cache-key was afhankelijk van een lockfile die vaak veranderde, en zelfs bij een cache-hit duurde het herstellen ongeveer 28 seconden, vergeleken met ongeveer 7,5 seconden voor een gefilterde installatie. De cache voegde tijd en variabiliteit toe zonder enig duidelijk voordeel.
Samen reduceerden deze drie wijzigingen de setup-tijd per shard met ongeveer 44%, van 110-140 seconden naar 67-73 seconden.
Ongewijzigde setup niet herhalen
Sommige setup-taken hoeven alleen herhaald te worden als de inputs veranderen. Onze API-containers voerden bijvoorbeeld bij elke run de volledige database-migratiehistorie uit, zelfs als een PR het schema niet had gewijzigd. In die gevallen zijn we overgestapt op het laden van een gegenereerde schema-snapshot en een bootstrap-bestand, waardoor de database-setup daalde van ongeveer 12 seconden naar 1-2 seconden per container.
Korte checks batchten in minder jobs
Zeven onafhankelijke checks startten elk een runner, checkten de repository uit en installeerden dependencies, om vervolgens slechts enkele seconden nuttig werk te verrichten. We hebben deze geconsolideerd tot twee jobs, waarbij de zeven taken gelijktijdig binnen deze jobs worden uitgevoerd. Dit verminderde het aantal keren dat we de setup-overhead betaalden van zeven naar twee. Op basis van het gebruik in juni bespaarde deze wijziging ongeveer 87.000 runner-minuten per maand, wat overeenkomt met 11,8% van ons totale CI-verbruik.
Testuitvoering efficiënter maken
Nu de vaste kosten per test-shard waren verlaagd, konden we de API-suite agressiever parallelliseren. Omdat dit het grootste en meest frequent uitgevoerde deel van onze workflow was, hadden verbeteringen hier een grote impact op de mergetijd.
Werk verdelen zoals de test-runner het ziet
Vitest, de test-runner die we gebruiken voor onze TypeScript-suites, verdeelt werk per bestand in plaats van per duur van individuele tests. Dit betekende dat een paar ongebruikelijk grote testbestanden een shard konden domineren en de voltooiing van de hele suite vertraagden.
We hebben deze grote bestanden opgesplitst in kleinere, meer gefocuste bestanden. Vervolgens evalueerden we verschillende shard- en runner-configuraties. We gingen van vier naar acht shards, wat de kritieke job in onze eerste benchmark ongeveer 19% sneller en 19% goedkoper maakte. Een week na de wijziging daalde de traagste shard van 5,25 minuten naar 4,33 minuten.
Module-state delen met strikte isolatieregels
Vitest isoleert normaal gesproken elk testbestand, wat voor ons betekende dat de entity-, GraphQL- en decorator-graph in elke test-shard opnieuw opgebouwd moesten worden. We hebben een opt-in Vitest-project geïntroduceerd met isolate: false, waardoor veilige bestanden een module-registry binnen elke worker kunnen delen.
Dit was onze grootste prestatieverbetering, met een maandelijkse besparing van ongeveer 17%. De traagste shard daalde van ongeveer 300-379 seconden naar ongeveer 195 seconden, terwijl de totale runner-tijd voor API-shards daalde van 32,8 naar 22 minuten per run.
Dit was ook de optimalisatie met het hoogste risico op fouten. We hebben de geschiktheid expliciet gemaakt met een opt-in commentaar op elk bestand en de nodige teardown voor gedeelde state toegevoegd. Een aantal bestanden die fake timers of gedeelde state gebruikten op een manier die we niet veilig konden ontwarren, hebben we in het geïsoleerde project gelaten. Omdat agents nu het grootste deel van onze tests schrijven, hebben we hun skills bijgewerkt zodat gegenereerde tests standaard dezelfde beperkingen volgen.
Sharding wordt beperkt door setup-overhead
Verdere sharding loont alleen als de vaste kosten per shard laag zijn, aangezien het verdubbelen van het aantal shards ook de tijd voor setup verdubbelt. De eerder genoemde setup-optimalisaties maakten acht shards praktisch mogelijk. Bij 110-140 seconden per shard zouden acht shards 15-19 minuten aan runner-tijd alleen al aan setup besteden, wat meer is dan de tests zelf. Nu de setup rond de 40 seconden ligt, besteden acht shards in totaal minder setup-tijd dan vier shards dat voorheen deden, terwijl de tests twee keer zo ver geparallelliseerd zijn.
Conclusie
Als we begin dit jaar geen bewuste inspanning hadden geleverd om de CI te verbeteren, zou de huidige testsuite nu ongeveer 11 minuten duren, bijna het dubbele van waar ontwikkelaars nu op wachten. Het werk stopt hier niet; onze codebase groeit constant (momenteel voegen we ongeveer 2.000 tests per week toe). De CI snel houden zal een voortdurende inspanning blijven, waarbij we de lessen uit dit proces toepassen op nieuwe bottlenecks.
Groetjes,