GoSuda

Git-monorepon uudelleenajattelu: CAS-Native-arkkitehtuuri ihmisille ja koodausagenteille

By Lemon Mint views ...

Jokainen kasvava ohjelmistotuotannon organisaatio päätyy lopulta samaan risteykseen. Kun aloitetaan kymmenillä pienillä repositorioilla, elämä tuntuu aluksi yksinkertaiselta. Jokaisella kirjastolla on oma ongelmanseurantansa, oma CI-putkensa ja git clone, joka valmistuu ennen kuin ehdit ottaa kulauksen kahvia. Sitten kustannus iskee. Muutat struct-rakennetta keskeisessä verkkopaketissa ja käytät loppupäivän avaamalla version päivityspull requesteja kuudessa jälkikäteisessä repositoriossa odottaen, että CI-jonot tyhjenevät topologisessa järjestyksessä.

Siirrä kaikki perinteiseen Git-monorepoon, ja vaihdat koordinointikivun fysiikkakipuun. Kun commit-historia syvenee ja binääriaineistot kertyvät vanhoihin revisioihin, git fetch hidastuu. Työpuut paisuvat gigatavuiksi. Pian joku ehdottaa mukautetun virtuaalisen tiedostojärjestelmän daemonin, erillisen koodikatselmointipalvelimen tai remote-buildaajien parven pystyttämistä vain siksi, että päivittäinen kehitystyö pysyisi siedettävänä.

Kun autonomiset koodausagentit liittyvät tiimiin, molemmat vikatilanteet pahenevat. Polyrepot sokaisevat agentin projektien välisille kutsujille ja pakottavat sen jongleeraamaan tunnuksia repositorioiden välillä. Perinteiset monorepot upottavat agentin konttilevyn ja verkkokaistan jokaisen tehtävän alustuksen yhteydessä, ja samaan haaraan samanaikaisesti kajoavat agentit aiheuttavat loputtomia rebase-kilpailutilanteita.

Halusimme kolmannen vaihtoehdon. Inspiroituneina google3-arkkitehtuurista esitimme yksinkertaisen kysymyksen: voimmeko saavuttaa yhteen trunkiin kohdistuvat atomiset muutokset, alle sekunnin kestävän sparse-käyttöönoton, hierarkkisen OWNERS-hallinnan ja itsenäiset avoimen lähdekoodin peilit käyttämällä pelkkiä vakiomuotoisia Git-perustoimintoja ja tavallista objektitallennustilaa?

Vastaus oli kyllä, eikä se vaatinut mukautettua tiedostojärjestelmädaemonia tai kolmannen osapuolen katselmointisovellusta. Se vaati vain sen, että Gitiä käytetään siihen, mitä se todellisuudessa on konepellin alla: Merkle-puuhun perustuva Content-Addressable Store (CAS).

Väärinkäsitys hitaiden Git-monorepojen ytimessä

Kun kehittäjät valittavat, ettei Git skaalaudu suuriin monorepoihin, he osuvat harvoin rajaan Gitin objektitietokannassa. Hakemistossa .git/objects Git tallentaa kolme muuttumatonta perusrakennetta, joita avaimena on niiden SHA-1- (tai SHA-256-) sisältötiiviste:

  1. blob: yksittäisen tiedoston raakasisältö tavuina.
  2. tree: hakemistolistaus, joka yhdistää tiedostonimet ja oikeudet blob- tai alihakemisto-tree-tiivisteisiin.
  3. commit: pieni tekstiotsikko (tyypillisesti 200–300 tavua), joka osoittaa yhteen juuri-tree-tiivisteeseen, nollaan tai useampaan vanhempi-commit-tiivisteeseen sekä tekijän metatietoihin.

Sisältöosoitteisena tallennusjärjestelmänä tämä rakenne on poikkeuksellisen tehokas. Jos monorepo sisältää 50 000 tiedostoa ja muokkaat yhtä riviä yhdessä tiedostossa, uusi commit käyttää uudelleen muiden 49 999 tiedoston täsmälleen samoja blob- ja tree-tiivisteitä.

Miksi perinteinen Git-monorepo sitten tuntuu raskaalta? Syynä on se, että konventio kuormittaa yhtä viitettä, refs/heads/main, kolmella keskenään ristiriitaisella tehtävällä:

  • Se pitää hallussaan kaikkien projektien koko yhtenäistä hakemistopuuta.
  • Se vetää perässään koko vanhempiin linkittyvän historiallisen commit-ketjun ensimmäisestä päivästä asti.
  • Se sijaitsee nimiavaruudessa refs/heads/*, jonka Git-palvelimet mainostavat ja jonka kanssa ne neuvottelevat oletuksena jokaisella git clone- ja git fetch-kerralla.

Aina kun kehittäjä tai CI-ajuri hakee viitteen refs/heads/main, asiakas ja palvelin neuvottelevat commit-graafin tavoitettavuudesta tuhansien historiallisten revisioiden yli. Jos joku kommitoi vahingossa 100 MB:n arkiston kolme vuotta sitten ja poisti sen seuraavana päivänä, sen blob roikkuu yhä historiallisen tree-puun varassa, joka on tavoitettavissa viitteestä refs/heads/main.

Viitenimiavaruuden jakaminen neljään kerrokseen

Gitin wire-protokolla mainostaa tavallisen kloonauksen aikana vain viitteet refs/heads/* ja refs/tags/*. Mukautettujen nimiavaruuksien, kuten refs/trunk/* tai refs/ws/*, viitteet elävät palvelimella täsmälleen samassa objektitallennuksessa, hyötyvät samasta packfile-deltapakkauksesta ja voidaan hakea tai työntää eksplisiittisesti. Ne eivät kuitenkaan aiheuta lisäkuormaa oletuskloonauksissa.

Jakamalla vastuut neljän viitenimiavaruuden kesken yhden repositorion sisällä pullonkaula katoaa:

1refs/heads/main          Vain hallintataso (~20 KB: säännöt, .gitconfig, synkronointiasetukset, skriptit)
2refs/trunk/main          Juuriton snapshot-commit, joka osoittaa uusimpaan yhtenäiseen juuri-treeen
3refs/trunk/history       Lineaarinen vanhempiin linkittyvä commit-ketju, joka jakaa täsmälleen saman juuri-treen SHA-1-tiivisteen
4refs/ws/<actor>/<id>     Eristetyt työtilan snapshotit (muutoslistat) kehittäjää tai agenttia kohden
5refs/heads/land/<id>     Väliaikainen yhdistämisjonon laukaisin, joka poistetaan heti hyväksymisen jälkeen

1. Hallintataso (refs/heads/main)

Oletushaara ei sisällä sovelluksen lähdekoodia. Se seuraa vain repositorion toimintasääntöjä (AGENTS.md, README.md), jaettua .gitconfig-tiedostoa työnkulkualiaksineen, Starlark-synkronointiasetuksia (sync.star) ja palvelinpuolen yhdistämisskriptejä. Oletushaaran kloonaus siirtää noin 20 KB ja valmistuu millisekunneissa.

2. Juuriton trunk-snapshot (refs/trunk/main)

refs/trunk/main osoittaa koko organisaation yhtenäiseen lähdepuuhun (//web/*, //net/*, //ipc/*, //devtools/*, //corp/*, //third_party/*, //experimental/*), mutta sen commit-objekti luodaan ilman lippua -p, eli ilman vanhempaa.

Koska viitteellä refs/trunk/main ei ole vanhempia (parents = []), sen hakeminen ei vaadi lainkaan commit-graafin neuvottelua. Git-palvelin ei kulje historiaa läpi, vaan lähettää yhden huippu-commitin ja sen nykyiset hakemisto-tree-objektit. Viime kuun historialliset binääritiedostot tai poistetut tiedostot eivät ole tavoitettavissa viitteestä refs/trunk/main, eikä niitä siirretä koskaan.

3. Lineaarinen tarkastushistoria (refs/trunk/history)

Kehittäjät tarvitsevat yhä git blame- ja git log-komentoja regressioiden selvittämiseen. Joka kerta kun muutos yhdistetään, yhdistämisskripti luo kaksi commit-objektia, jotka osoittavat täsmälleen samaan juuri-treen $NEW_TREE-SHA-1-tiivisteeseen:

 1# Juuriton snapshot-commit välittömiä depth-1-työtilan uloskirjauksia varten
 2SNAP=$(git commit-tree "$NEW_TREE" -m "$RAW_MSG")
 3
 4# Vanhempiin linkittyvä commit lineaarista git logia ja git blamea varten
 5HIST=$(git commit-tree "$NEW_TREE" -p "$OLD_HIST" -m "$RAW_MSG")
 6
 7git push --atomic origin \
 8  --force-with-lease="refs/trunk/main:${OLD_MAIN}" \
 9  --force-with-lease="refs/trunk/history:${OLD_HIST}" \
10  "${SNAP}:refs/trunk/main" \
11  "${HIST}:refs/trunk/history"

Kaksinkertaistaako refs/trunk/main- ja refs/trunk/history-viitteiden ylläpito repositorion tallennustilan tarpeen? Ei lainkaan. Koska molemmat commitit viittaavat samaan $NEW_TREE-tiivisteeseen, Gitin sisältöosoitteinen tallennus poistaa päällekkäisyydet 100-prosenttisesti tree- ja blob-objekteista. Juurettoman snapshotin lisäkustannus on vain yhden 250 tavun commit-otsikon verran.

4. Eristetyt työtilat (refs/ws/<actor>/<id>)

Kesken olevat työhaarat työnnetään nimiavaruuteen refs/ws/<actor>/<id> eikä nimiavaruuteen refs/heads/*. Sata koodausagenttia voi tallentaa tarkistuspisteitä joka minuutti ilman, että haaravalikot täyttyvät tai ihmisinsinöörien git fetch hidastuu.

Käyttöönotto viidellä komennolla: miten sparse- ja blobless-uloskirjaus toimii

Tällä viiterakenteella uusi kehittäjä tai väliaikainen koodausagenttikontti ei asenna mukautettua CLI-työkalua tai FUSE-ajuria. Käyttöönotto vaatii viisi tavallista Git-komentoa:

 1# 1. Kloonaa 20 KB:n hallintataso blob-suodatuksella ja sparse-tila käytössä
 2git clone --filter=blob:none --sparse https://github.com/example-org/monorepo.git
 3cd monorepo
 4
 5# 2. Lataa repositorion aliakset ja liitä refs/trunk/*
 6git config include.path ../.gitconfig
 7git trunk-init
 8
 9# 3. Määritä tehtävän vaatimat aluehakemistot
10git sparse-checkout set net/relay ipc/codec
11
12# 4. Luo eristetty työtila uusimman trunk-snapshotin päälle
13git ws-new alice/optimize-framing

Konepellin alla jokainen vaihe tekee vain tarpeellisen vähimmäistyön:

  1. git clone --filter=blob:none --sparse lataa 20 KB:n hallintatason viitteestä refs/heads/main.
  2. git trunk-init määrittää asetuksen +refs/trunk/*:refs/remotes/origin/trunk/* ja suorittaa blobless-haun. Git lataa viitteen refs/trunk/main tree-metatiedot (tyypillisesti muutamia kymmeniä kilotavuja) ja nolla tiedostojen blob-objektia.
  3. git sparse-checkout set net/relay ipc/codec ohjaa Gitin cone-tilan indeksisuodatinta toteuttamaan vain juurihakemiston ja nuo kaksi alipuuta.
  4. git ws-new siirtyy uuteen haaraan, jonka juuri on origin/trunk/main. Juuri silloin Git pyytää tarpeen mukaan vain kohteiden net/relay ja ipc/codec sisällä olevat blob-objektit. Loput 95 prosenttia monorepostä ei koskaan lataudu levylle.

Tuleva näkymä: viitteen refs/trunk/main liittäminen virtuaaliseksi tiedostojärjestelmäksi (VFS)

Sparse-uloskirjaus blobless-kloonauksilla kattaa päivittäiset työnkulut ilman ylimääräisiä asiakastyökaluja. Koska refs/trunk/main on jo puhdas Merkle-puu sisältöosoitteisessa tallennuksessa, arkkitehtuuri tarjoaa myös suoran perustan valinnaiselle käyttäjätilan virtuaaliselle tiedostojärjestelmälle (VFS): FUSE Linuxilla, FileProvider macOS:llä tai ProjFS Windowsilla.

  1. Välitön koko puun liittäminen: kevyt paikallinen liitäntädaemon lukee viitteen refs/trunk/main juuri-tree-objektin ja projisoi koko monorepon hakemistorakenteen työhakemistoon millisekunneissa. Komentoa git sparse-checkout set ei tarvita.
  2. Tarpeen mukainen blob-haku: tiedostojen sisältö pysyy virtuaalisena, kunnes kääntäjä, IDE tai agentti kutsuu järjestelmäkutsua open(2) tai read(2). Daemon hakee tällöin vastaavan SHA-1-blob-objektin CAS-päätepisteestä ja tallentaa sen paikalliselle NVMe-tallennustilalle.
  3. Copy-on-write-peite työtiloille: paikalliset muokkaukset kirjoittavat kevyeen ylimpään peitekerrokseen. Komento git ws-save tiivistää vain peitteen likaiset polut, kirjoittaa päivittyneet blob- ja tree-objektit ja työntää tuloksena syntyneen commitin viitteeseen refs/ws/<actor>/<id> skannaamatta muuttumattomia hakemistoja.

Muutoslistamalli puhtaalla Gitillä: muistissa tapahtuva palvelinpuolen hyväksyntä

Perinteisessä Git-työnkulussa ominaisuushaaran yhdistäminen joko luo kaksivanhemmaisen yhdistämis-commitin, joka muuttaa historian sekaantuneeksi raidesäiliöksi, tai vaatii kehittäjien käsin tekemää rebasea ja kilpajuoksua muiden kehittäjien työntöjen kanssa haaraan main.

Google3-arkkitehtuurin Changelist (CL) -mallin innoittamana jokainen työtila tässä arkkitehtuurissa edustaa yhtä atomista muutosta suhteessa trunk-snapshotiin. Kun kehittäjä tai agentti työskentelee paikallisesti hakemistossa ws/alice/optimize-framing, se voi tehdä kymmenen askelittaista commitia (wip, fix tests, lint). Kun muutos on valmis hyväksyttäväksi, palvelin tiivistää työtilan muutokset yhdeksi lineaariseksi trunk-revisioksi ilman, että CI-ajuri uloskirjoittaa koskaan työhakemistoa.

Näin hyväksyntäskripti (land.sh) suoritetaan muistissa Git 2.40+ -perustoiminnoilla:

 1# 1. Tunnista juuri-snapshot-commit, josta työtila haarautui
 2BASE=$(git rev-list --max-parents=0 "$WORK_SHA")
 3
 4BASE_TREE=$(git rev-parse "${BASE}^{tree}")
 5MAIN_TREE=$(git rev-parse "${OLD_MAIN}^{tree}")
 6WORK_TREE=$(git rev-parse "${WORK_SHA}^{tree}")
 7
 8# 2. Laske yhdistetty juuri-tree muistissa ilman työpuun uloskirjausta
 9if [ "$MAIN_TREE" = "$BASE_TREE" ]; then
10  NEW_TREE="$WORK_TREE"
11elif OUT=$(git merge-tree --write-tree --merge-base="$BASE_TREE" "$MAIN_TREE" "$WORK_TREE"); then
12  NEW_TREE=$(head -n1 <<<"$OUT")
13else
14  echo "Merge conflict detected; run git ws-sync to rebase." >&2
15  exit 1
16fi

Tarkastele vaihetta 2 tarkasti:

  • Riippumatta siitä, kuinka monta paikallista commitia työtilan haaraan on tehty, skripti vertaa vain kolmea tree-tiivistettä: alkuperäistä base-snapshotia ($BASE_TREE), nykyistä trunk-snapshotia ($MAIN_TREE) ja lopullista työtilan puuta ($WORK_TREE).
  • Jos refs/trunk/main eteni kohdassa //web/blog, kun Alice muokkasi kohdetta //net/relay, git merge-tree --write-tree laskee yhdistetyn juuri-treen $NEW_TREE suoraan Gitin objektitallennuksessa millisekunneissa. Ajurin tiedostojärjestelmään ei kirjoiteta yhtään tiedostoa.
  • Lopuksi git push --atomic yhdessä --force-with-lease-asetuksen kanssa päivittää viitteen refs/trunk/main, lisää yhden lineaarisen commitin viitteeseen refs/trunk/history ja poistaa laukaisimen refs/heads/land/<id> yhdessä transaktiossa.

Arkkitehtuurirajojen valvonta: nelitasoinen näkyvyys ja hierarkkinen OWNERS

Koodin yhdistäminen yhteen repositorioon toimii vain, jos domainien väliset rajat valvotaan mekaanisesti ennen kuin commit voidaan hyväksyä. Heti kun git merge-tree --write-tree on tuottanut $NEW_TREE-puun (ja ennen kuin git push --atomic päivittää trunkin), käytännön moottori suorittaa kaksi O(delta)-tarkistusta komennon git diff-tree "$MAIN_TREE" "$NEW_TREE" tuloksille.

1. Nelitasoinen domainien näkyvyys

Juuri-puu on jaettu neljään näkyvyystasoon:

HakemistoprefiksiTasoSallitut tuonnitKielletyt tuonnitOWNERS-portti
//web/*, //net/*, //ipc/*, //storage/*, //crypto/*, //ai/*, //devtools/*Julkinen tuotantoJulkinen tuotanto, //third_party/*//corp/*, //experimental/*Pakotettu
//third_party/*Ulkoinen toimittajaItsenäinen tai //third_party/*Kaikki muut kuin third_partyPakotettu
//corp/*Sisäinen yksityinenJulkinen tuotanto, //corp/*, //third_party/*//experimental/*Pakotettu
//experimental/<actor>/*HiekkalaatikkoMikä tahansa muu kuin corp-paketti//corp/*Vapautettu (automaattinen hyväksyntä)

Jos julkinen paketti kohteessa //web/* yrittää tuoda sisäisen paketin kohteesta //corp/* tai keskeneräisen prototyypin kohteesta //experimental/*, hyväksyntätyö keskeytyy ennen kuin se koskee viitettä refs/trunk/main.

2. Hierarkkinen OWNERS-tarkistus ilman Gerrit-palvelinta

Hakemistotason OWNERS-tiedostojen periytymissääntöjen valvontaan ei tarvita omistettua Gerrit-instanssia:

  • Jokaiselle polulle, joka on muuttunut $MAIN_TREE- ja $NEW_TREE-puiden välillä, tarkistaja kulkee tiedoston hakemistosta ylöspäin kohti tiedostoa //OWNERS.
  • Mikä tahansa OWNERS-tiedosto voi määrittää set noparent, jolla estetään vanhemmista hakemistoista periytyvät hyväksynnät, tai per-file *.sql=@dba-team, jolla hyväksynnät rajataan tiedostonimien glob-mallien mukaan.
  • Manipulointisuoja: Olemassa olevat OWNERS-tiedostot luetaan aina puusta $MAIN_TREE (nykyinen trunk-puu), ei koskaan puusta $NEW_TREE. Hyväksymätön kirjoittaja ei voi myöntää itselleen oikeuksia muokkaamalla tiedostoa OWNERS samassa työtilan commitissa.
  • Hiekkalaatikkovapautus: Kaikki polut kohdassa //experimental/<actor>/* ohittavat OWNERS-tarkistukset, jotta insinöörit ja agentit voivat prototyypittää vapaasti komennolla git ws-exp <actor> <project> ja myöhemmin siirtää paketin tuotantodomainiin komennolla git ws-graduate.

Vaivaton koodikatselmointi: ephemeral-PR:t ja snapshot-diffit

Koska refs/heads/main sisältää vain hallintatason ja refs/trunk/main on juurettomana commitina, tavallisen GitHub-pull requestin avaaminen suoraan työtilan haarasta main-haaraan vertaisi toisiinsa erillisiä historioita. Ratkaisimme tämän kolmella katselmointityönkululla, jotka on räätälöity eri muutostyypeille:

  1. Välittömät kaksipisteiset työtilan diffit (git ws-save + reunadiff-reititin) Kun työtila työnnetään komennolla git ws-save, viite refs/ws/<actor>/<id> tulee näkyviin kevyen reunaworkerin kautta. Worker selvittää viitteiden refs/trunk/main ja työtilaviitteen commit-tiivisteet ja ohjaa katselijan GitHubin kaksipisteiseen vertailunäkymään (/compare/<trunk-sha>..<ws-sha>). Pienissä muutoksissa tai agenttien tuottamissa muutoksissa katselmoijat voivat tarkastella diffiä ja napsauttaa suoraan web-käyttöliittymän painiketta Land.
  2. Ephemeral GitHub Pull Requestit (git ws-pr) Rivikohtaista katselmointia varten GitHubissa komento git ws-pr työntää juuri-snapshot-commitin ($(git rev-list --max-parents=0 HEAD)) viitteeseen refs/heads/base/<id> ja työtilan kärjen viitteeseen refs/heads/review/<id>. Sen jälkeen se avaa GitHub-pull requestin, joka vertaa haaraa review/<id> haaraan base/<id>. Koska perushaara on täsmälleen se snapshot, josta työtila haarautui, GitHubin välilehti Files changed näyttää vain ne rivit, jotka työtilassa on todella muokattu. Kun katselmoija napsauttaa painiketta Merge pull request, webhook siirtää huippu-commitin viitteeseen refs/heads/land/<id>, suorittaa muistissa tapahtuvan hyväksyntäputken ja poistaa automaattisesti sekä viitteen refs/heads/base/<id> että viitteen refs/heads/review/<id>.
  3. Ulkoiset peilin pull requestit Avoimen lähdekoodin avustajien ei tarvitse oppia monorepo-työnkulkua. He tekevät forkin ja avaavat pull requestit itsenäisiin peilirepositorioihin tavanomaisesti.

O(delta)-kaksisuuntainen synkronointi ja erilliset tuotantohaarat

Monet organisaatiot julkaisevat yksittäiset paketit erillisinä GitHub-repositorioina, jotta ulkopuoliset käyttäjät voivat suorittaa go get- tai cargo install-komennon tai forkata yksittäisen projektin koskematta monorepoon. Jotkin projektit, kuten staattiset blogit tai julkaisuportitetut web-sovellukset, ylläpitävät myös erillistä haaraa production haaran main rinnalla.

Starlark-synkronointimoottorimme käsittelee sekä lähteviä vientejä että saapuvia avustuksia ajassa O(delta), joka on verrannollinen commitissa muuttuneiden tiedostojen määrään eikä monorepon kokoon:

  • Blobless-indeksin liittäminen: Kun peilirepositorion alipuu liitetään monorepon juuri-puuhun tai poistetaan siitä, moottori asettaa muuttujan GIT_NO_LAZY_FETCH=1 ja suorittaa komennon git update-index --index-info sekä sen jälkeen komennon git write-tree --missing-ok. Muuttumattomat alipuut ja blobit viitataan puhtaasti niiden SHA-1-tiivisteillä, eikä niitä koskaan ladata CI-ajurille.
  • Vain muutosten blob-ennakkohaku: Ennen viedyn commitin työntämistä itsenäiseen peiliin skripti suorittaa komennon git diff-tree -r --diff-filter=AM "$ext_parent" "$export_tree" ja hakee komennolla git cat-file --batch vain muokattujen tiedostojen vanhat ja uudet blobit. Git voi sen jälkeen laskea kevyen delta-paketin ja työntää sen peilirepositorioon sekunneissa.
  • Työnkulkujen ja julkaisuhaarojen säilyttäminen: Ulospäin suuntautuvan viennin aikana synkronoija säilyttää kunkin itsenäisen repositorion oman hakemiston .github/workflows/, jotta julkiset CI-ajurit suorittavat projektin testit peilillä. Jokainen Starlark-asetuksen työnkulku kohdistuu eksplisiittisesti johonkin haaraan, kuten main, mikä säilyttää julkaisuportit repositorioille, jotka julkaisevat erillisestä haarasta production. Monorepon hyväksynnät synkronoidaan peilin haaraan main, ja haaraan main tehdyt automaattiset metatieto- tai käännöscommitit synkronoituvat takaisin viitteeseen refs/trunk/main webhookin kautta. Haaran main siirtäminen haaraan production on edelleen eksplisiittinen julkaisuvaihe.

Loppupohdinta

Nopeatempoisen monorepon rakentaminen ei vaadi tavallisesta Gitistä luopumista eikä monimutkaisen räätälöityjen infrastruktuuripalvelinten parven ylläpitoa. Tämä arkkitehtuuri käsittelee Gitin objektitallennusta sisältöosoitteisena tallennuksena, erottaa viitteet refs/heads/main ja refs/trunk/main sekä refs/trunk/history toisistaan ja yhdistää puut muistissa komennolla git merge-tree --write-tree. Tämän ansiosta sekä ihmisinsinöörit että autonomiset koodausagentit voivat kloonata työtilan alle sekunnissa ja skaalautua siististi satojen projektien yli.