Unternehmen

Cal.ai

Preisgestaltung

Unternehmen

Cal.ai

Preisgestaltung

Durch

Keith Williams

01.01.2026

Wie wir unsere CI-Wartezeit von 30 Minuten auf 5 Minuten verkürzt haben

Wie wir unsere CI-Wartezeit von 30 Minuten auf 5 Minuten verkürzt haben

Wie wir unsere CI-Wartezeit von 30 Minuten auf 5 Minuten verkürzt haben

Langsame CI ist eine dieser Dinge, die die Zahnräder von Ingenieurteams verklemmen. Ingenieure warten länger auf Feedback, das Kontextwechseln nimmt zu, und die Versuchung, Änderungen zu bündeln, wächst, was die Situation nur verschlimmert. Wir haben einige Wochen damit verbracht, unsere GitHub Actions-Workflows zu optimieren und unsere PR-Überprüfungen von 30 Minuten auf 5 Minuten zu reduzieren. Das entspricht einer Reduzierung der Wartezeit um 83 %, ohne irgendetwas an Testabdeckung oder Qualitätssicherungsmaßnahmen einzubüßen.

Es gab keine einzige magische Lösung. Wir haben uns angeschaut, wo die Zeit tatsächlich verloren geht, und drei Prinzipien angewendet: intelligente Jobabhängigkeiten, aggressives Caching und Test-Sharding. Hier ist, was wir gelernt haben.

Die Grundlage: Migration zu Blacksmith

Wir hatten tatsächlich bereits versucht, zu Blacksmith zu migrieren, sind jedoch auf Probleme gestoßen. Mit einem main Branch, der so aktiv ist wie unserer, wollten wir keine CI-Unstabilität haben. Dieses Mal war die Migration unkompliziert (#26247): ersetze buildjet-*vcpu-ubuntu-2204 Runner durch blacksmith-*vcpu-ubuntu-2404 in 28 Workflow-Dateien, tausche die cache und setup-node Aktionen aus und passe die vCPU-Zuweisungen an. Das einzige echte Problem, auf das wir stießen, war das Problem mit dem beschädigten Cache der .next Dateien (mehr dazu im Abschnitt über Caching).

Nachdem wir das Upgrade durchgeführt hatten, bemerkten wir eine sofortige 2-fache Geschwindigkeitsverbesserung beim Abrufen des Caches. Da die Installation von yarn 1,2 GB umfasst, spart das etwa 22 Sekunden bei jedem Job, der yarn installieren muss, was die meisten der Fälle sind. Wir nutzen auch Blacksmiths Container-Caching für Dienste, die in vielen Jobs verwendet werden (Postgres, Redis, Mailhog). Der Initialize containers Job ist jetzt um über 50% gesenkt worden, von 20 Sekunden auf 9 Sekunden.

jobs:
  build:
    name: Build Web App
    runs-on: blacksmith-4vcpu-ubuntu-2404
    steps:
      - uses: actions/checkout@v4
      - uses: ./.github/actions/yarn-install
      - uses

Was wir beheben mussten

Blacksmith-Runner zeigen die Anzahl der CPU-Kerne der Hostmaschine an und nicht die zugewiesenen vCPUs (typischerweise 4). Playwright, das hilfreich ist, würde Arbeiter starten, um dem zu entsprechen, was zu Ressourcenengpässen und fehlerhaften Testausfällen führt. Wir mussten die Arbeiter einfach mit --workers=4 explizit begrenzen, um unserer tatsächlichen Zuweisung zu entsprechen.

Wir entdeckten auch, dass höherer Parallelismus Wettlaufbedingungen in unserer Testbereinigung aussetzte. Integrationstests, die sequenziell gut funktionierten, kollidierten, wenn sie gleichzeitig ausgeführt wurden, was idempotencyKey Fehler erzeugte. Die Lösung erforderte ein Umdenken, wie Tests sich selbst bereinigen: Abfragen von Buchungen per eventTypeId anstelle der Verfolgung individueller Buchungs-IDs, die Buchungen verpasst, die indirekt durch den getesteten Code erstellt wurden (#26269, #26278, #26283).

Sogar unsere Screenshot-Tests mussten angepasst werden (#26247). Verschiedene Runner haben subtile Unterschiede in der Schriftartdarstellung, daher haben wir deviceScaleFactor: 1 für eine konsistente Wiedergabe hinzugefügt und unseren Differenzschwellenwert leicht von 0.05 auf 0.07 erhöht, um Antialiasing-Variationen zu berücksichtigen, ohne echte Regressionen zu maskieren.

Schneller zu werden, legt oft versteckte Kopplungen in Ihrem System offen. Die Kopplung war immer vorhanden; man konnte sie nur bei geringeren Geschwindigkeiten nicht sehen. Diese Probleme zu beheben, ermöglicht nachhaltige Geschwindigkeitsverbesserungen.

Prinzip 1: Den kritischen Pfad mit Abhängigkeitsdesign verkürzen

Für uns war ein großes Problem, dass Lint Dinge blockierte, die es nicht blockieren sollte. Wenn Ihre E2E-Tests 7-10 Minuten dauern, sie aber auf 2-3 Minuten Lint- und Typüberprüfungen zu blockieren, verlängert das den gesamten Workflow nur. Das Lint wird lange bevor die E2E-Tests abgeschlossen sind, fertiggestellt, also warum nicht parallel laufen lassen?

Entkoppeln Sie nicht verwandte Test-Suiten

Unsere Integrationstests und die Haupt-E2E-Suite benötigen nicht den API v2-Bau, also haben wir diese Abhängigkeit entfernt (#26170). Umgekehrt brauchen die API v2 E2E-Tests nicht den Haupt-Web-Bau. Jede Test-Suite hängt jetzt nur noch von dem ab, was sie tatsächlich benötigt, was maximalen Parallelismus ermöglicht.

Konsolidierung von Preflight-Jobs

Wir hatten separate Jobs für die Dateiänderungserkennung (changes) und die E2E-Labelprüfung (check-label). Jeder Job hat einen Start-Overhead: Ein Runner muss hochgefahren werden, der Code muss ausgecheckt werden, der Kontext muss wiederhergestellt werden. Indem wir diese in einen einzigen prepare Job zusammenfassen, haben wir redundanten Overhead eliminiert (#26101). Wir sind noch weiter gegangen und haben unsere Schritte zur Abhängigkeitsinstallation auch in den prepare Job verschoben, was weitere 20 Sekunden pro Workflow einsparte, indem wir noch einen Job-Grenzpunkt eliminierten (#26320).

Berichterstattung nicht blockierend gestalten

E2E-Testberichte sind wertvoll, aber sie sollten nicht auf dem kritischen Pfad sitzen. Wir haben die merge-reports, publish-report und cleanup-report Jobs in einen separaten Workflow verschoben, der durch workflow_run ausgelöst wird (#26157). Jetzt können Ingenieure fehlgeschlagene Jobs sofort erneut ausführen, ohne darauf warten zu müssen, dass die Berichtserstellung abgeschlossen ist.

name: E2E Report

on:
  workflow_run:
    workflows: ["PR Update"]
    types

Die Regel ist einfach: Seien Sie großzügig damit, E2E-Jobs früher zu starten als später. Blockieren Sie lang laufende Jobs nicht auf kurz laufenden Voraussetzungen, es sei denn, es liegt eine echte Abhängigkeit vor. Die Wand-Uhrzeit Ihres CI wird durch den kritischen Pfad bestimmt, und jede unnötige Abhängigkeit verlängert diesen Pfad.

Prinzip 2: Caching ist König (aber nur, wenn es trifft)

Effektives Caching erfordert Schlüssel, die ungültig werden, wenn sie es sollten, stabil bleiben, wenn sie es sollten, und einen Cache-Speicher, der im Laufe der Zeit gesund bleibt.

Nutzen Sie lookup-only

Unsere dramatischste Verbesserung beim Caching kam durch eine einfache Beobachtung: Wir luden etwa 1,2 GB an zwischengespeicherten Daten herunter, selbst wenn wir nichts installieren mussten. Der Cache war vorhanden, der Treffer war erfolgreich, aber wir bezahlten weiterhin die Downloadkosten, bevor wir herausfanden, dass wir den Installationsschritt überspringen konnten.

Die Lösung bestand darin, lookup-only Cache-Überprüfungen zu verwenden (#26314). Wir haben spezielle Schritte, die vor allen Jobs ausgeführt werden, um sicherzustellen, dass die Abhängigkeiten installiert sind, sodass wir zum Zeitpunkt der Ausführung einzelner Jobs wissen, dass der Cache existiert. Bevor wir versuchen, den vollständigen Cache wiederherzustellen, überprüfen wir zunächst, ob er vorhanden ist, ohne herunterzuladen. Wenn alle Caches erfolgreich sind, überspringen wir den gesamten Wiederherstellungs- und Installationsfluss. Keine Downloads, keine Installationen, einfach zur eigentlichen Arbeit übergehen. Dies allein spart in jedem Cache-Hit-Szenario erheblich Zeit, was bei richtiger Design des Cache-Schlüssels die meisten Durchläufe sein sollten.

- name: Check yarn cache (lookup-only)
  if: ${{ inputs.skip-install-if-cache-hit == 'true' }}
  uses: actions/cache/restore@v4
  id: yarn-download-cache-check
  with:
    path: ${{ steps.yarn-config.outputs.CACHE_FOLDER }}
    key: yarn-download-cache-${{ hashFiles('yarn.lock') }}
    lookup-only: true

Wir haben das gleiche Konzept auch auf die Installation des Playwright-Browsers angewendet (#26060). Da wir einen speziellen Schritt haben, der sicherstellt, dass Playwright-Browser vor der Ausführung der Testjobs zwischengespeichert werden, können wir den gleichen lookup-only Ansatz verwenden. Wenn der Playwright-Cache trifft, überspringen Sie die Installation vollständig. Jetzt überprüfen wir zuerst und entscheiden dann, ob wir überhaupt herunterladen.

Wir haben dieses Muster auch auf unseren Datenbank-Cache ausgeweitet (#26344). Mithilfe von lookup-only Überprüfungen können wir das Wiederaufbauen des DB-Caches überspringen, wenn er bereits existiert. Wir haben auch den Cache-Schlüssel vereinfacht, indem wir die PR-Nummer und den SHA entfernt haben, wodurch er viel wiederverwendbarer über verschiedene Durchläufe hinweg gemacht wurde.

Cache-Korrektheit

Die Korrektheit des Caches ist ebenso wichtig wie die Geschwindigkeit des Caches. Wir entdeckten, dass unser **/node_modules/ Glob-Muster apps/web/.next/node_modules/ erfasste, das den Build-Ausgang von Next.js enthält, einschließlich Symlinks und generierten Dateien, die sich während der Builds ändern. Dies führte zu beschädigten Cache-Archiven mit tar-Extraktionsfehlern (#26268). Die Lösung war ein einfaches Ausschlussmuster, aber es erforderte ein genaues Verständnis dessen, was gecached wurde und warum.

Wir haben auch die automatische Bereinigung des Caches implementiert (#26312). Wenn ein PR gemergt oder geschlossen wird, löschen wir jetzt die zugehörigen Build-Caches. Dies hält unseren Cache-Speicher schlank und verhindert die Ansammlung veralteter Einträge, die nie wieder verwendet werden würden. Wir haben dabei unser Format des Cache-Schlüssels vereinfacht, indem wir unnötige Segmente wie runner.os und node_version entfernt haben, die Komplexität ohne Verbesserung der Trefferquote hinzufügten.

delete-cache-build-on-pr-close:
  if: github.event_name == 'pull_request' && github.event.action == 'closed'
  runs-on: blacksmith-2vcpu-ubuntu-2404
  env:
    CACHE_NAME: prod-build
  steps:
    - name: Delete cache-build cache
      uses: useblacksmith/cache-delete@v1
      with:
        key: ${{ env.CACHE_NAME }}-${{ github.event.pull_request.head.ref }}
        prefix: "true"

Turbo Remote Caching

Wir haben auf Next.js 16 aktualisiert (#26093), was unsere Builds von über 6 Minuten auf etwa 1 Minute reduzierte (danke an das Next.js-Team für die enormen Verbesserungen). Mit aktivierten Turbo Remote Caching benötigen nachfolgende Builds nur 7 Sekunden, um aus dem Cache zu ziehen, verglichen mit etwa 1 Minute für einen vollständigen Build.

Wir haben auch das Turbo Remote Caching für unsere API v2 E2E-Tests aktiviert (#26331). Zuvor musste jede E2E-Shard die Plattformpakete von Grund auf neu erstellen. Jetzt profitieren alle Shards vom Remote-Cache von Turbo, und wir optimierten die TypeScript-Kompilierung von Jest, indem wir isolatedModules aktivieren und die Diagnosen im CI deaktivieren, was die Startzeit der Tests erheblich beschleunigt.

Prinzip 3: Tests mit Sharding skalieren

Wenn Sie mehr als 1.100 Testfälle in 82 E2E-Testdateien haben, lässt das sequentielle Ausführen dieser Tests die Leistung auf der Strecke. Test-Sharding, das Ihre Test-Suite über mehrere parallele Runner aufteilt, ist der direkteste Weg, die Wand-Uhrzeit für große Test-Suiten zu reduzieren. Wir hatten bereits Sharding für unsere Haupt-E2E-Suite eingerichtet, aber wir erhöhten es von 4 auf 8 Shards (#26342), was die gesamte E2E-Suite-Zeit um eine weitere Minute verringerte. Unsere API v2 E2E-Tests liefen immer noch als ein einzelner Job.

Wir shardeten unsere API v2 E2E-Tests in 4 parallele Jobs mit der integrierten --shard Option von Jest (#26183). Jeder Shard läuft unabhängig mit seinen eigenen Postgres- und Redis-Diensten, und Artefakte werden eindeutig pro Shard benannt. Was zuvor als einzelner Job über 10 Minuten in Anspruch nahm, wird nun erheblich schneller abgeschlossen, wenn die Arbeit auf vier Runner verteilt ist.

- name: Run Tests
  working-directory: apps/api/v2
  run: |
    yarn test:e2e:ci --shard=${{ matrix.shard }}/${{ strategy.job-total }}
    EXIT_CODE=$?
    echo "yarn test:e2e:ci --shard=${{ matrix.shard }}/${{ strategy.job-total }} command exit code: $EXIT_CODE"
    exit $EXIT_CODE

Sharding in großem Maße legt auch Infrastrukturprobleme offen, die Sie bei sequentieller Ausführung möglicherweise nicht bemerken. Wenn mehrere Jobs versuchen, gleichzeitig den gleichen Datenbank-Cache zu füllen, entstehen Wettlaufbedingungen. Wir haben dies gelöst, indem wir einen speziellen setup-db Job erstellt haben, der vor allen E2E- und Integrationstest-Jobs ausgeführt wird (#26171). Dieser einzelne Job füllt den Datenbank-Cache einmal und alle nachgelagerten Jobs stellen von ihm wieder her. Keine Wettläufe, keine Doppelarbeit.

Wir haben auch einen speziellen Workflow für API v2 Unit Tests erstellt, um sie von der Haupt-Test-Suite zu trennen (#26189). Dadurch können sie parallel mit anderen Überprüfungen ausgeführt werden, anstatt um Ressourcen in einem monolithischen Testjob zu konkurrieren.

Der kumulative Effekt

Keine dieser Änderungen hätte isoliert unsere CI-Zeit um 83% verkürzt. Die Gewinne resultieren aus ihrer Kombination.

Intelligente Abhängigkeiten lassen Jobs früher starten. Effektives Caching bedeutet, dass diese Jobs weniger Zeit mit der Einrichtung verbringen. Sharding bedeutet, dass die eigentliche Testausführung parallel erfolgt. Zusammen komprimieren sie den kritischen Pfad aus jeder Richtung.

Die Investition kumuliert im Laufe der Zeit. Jeder PR erhält nun schnellere Rückmeldungen. Ingenieure bleiben länger im Fluss. Die Versuchung, Änderungen zu bündeln, nimmt ab, was kleinere, besser überprüfbare Differenzen bedeutet. Die Codequalität verbessert sich, da der Feedbackzyklus enger ist.

Wir werden weiterhin in die CI-Leistung investieren, weil die Renditen real und messbar sind. Das Ziel ist nicht Geschwindigkeit um der Geschwindigkeit willen; es geht darum, Ingenieuren zu ermöglichen, qualitativ hochwertige Software schneller zu liefern. Wenn CI schnell ist, arbeiten schnelles Handeln und die Aufrechterhaltung hoher Standards zusammen, anstatt gegeneinander zu wirken.

Referenzierte Pull-Requests

Grundlage: Migration von Buildjet zu Blacksmith

  • #26247 - GitHub-Workflows von Buildjet zu Blacksmith migrieren

Teststabilität

  • #26269 - Flaky E2E-Tests mit isolierten Benutzersitzungen beheben

  • #26278 - Stabilisieren Sie E2E-Tests mit Scoped-Elementen und deterministischen Zeitplänen

  • #26283 - Testisolierung für verwaltete E2E-Tests des Veranstaltungstyps verbessern

Turbo Remote Caching

  • #26093 - Upgrade auf Next 16 (Builds von über 6 Minuten auf ~1 Minute reduziert)

  • #26331 - Turbo Remote Caching aktivieren (nachfolgende Builds benötigen 7s)

Jobabhängigkeiten und Workflow-Optimierung

  • #26101 - Änderungen und Check-Label-Jobs in den prepare zusammenführen

  • #26170 - Nicht-API v2-Tests von API v2-Bau entkoppeln

  • #26157 - E2E-Berichtjobs nicht-Blockierend gestalten, indem sie in einen separaten Workflow verschoben werden

  • #26320 - Verschieben Sie die Abhängigkeiten in den prepare Job-Schritt, um ~20s pro Workflow zu sparen

Nutzung von lookup-only

  • #26314 - Verwenden Sie die Lookup-Only-Cache-Prüfung, um Downloads im Abhängigkeitsjob zu überspringen

  • #26060 - Überspringen der Playwright-Installation bei Cache-Hit

  • #26344 - Verwendung von Lookup-Only für den DB-Cache und Vereinfachung des Cache-Schlüssels

Cache-Korrektheit

  • #26268 - Ausschluss von .next/node_modules vom yarn-Cache, um Korruption zu verhindern

  • #26312 - Cache-Entwicklungen bei PR-Schluss löschen

Test-Sharding und Infrastruktur

  • #26342 - Anzahl der Haupt-E2E-Shards von 4 auf 8 erhöhen (spart 1 Minute)

  • #26183 - API v2 E2E-Tests in 4 parallele Jobs aufteilen

  • #26171 - Hinzufügen eines speziellen Setup-DB-Jobs, um Cache-Wettlaufbedingungen zu beseitigen

  • #26189 - Neuen Workflow für API v2-Unit-Tests erstellen

Beginnen Sie noch heute kostenlos mit Cal.com!

Erleben Sie nahtlose Planung und Produktivität ohne versteckte Gebühren. Melden Sie sich in Sekunden an und beginnen Sie noch heute, Ihre Planung zu vereinfachen, ganz ohne Kreditkarte!