From 1f221ba8b87935595b87d39e2d76f752835f9c56 Mon Sep 17 00:00:00 2001
From: Ben Gamari <ben@smart-cactus.org>
Date: Tue, 5 Nov 2019 13:42:52 -0500
Subject: [PATCH] ci: Support splitting build across multiple jobs

This utilizes GitLab CI's `parallel`[1] field to divide the build into
several jobs, each handling a subset of the built packages. The job
count is a bit of a trade-off between build re-use and parallelism. I'm
trying 5 for now.

[1] https://docs.gitlab.com/ee/ci/yaml/#parallel
---
 .gitlab-ci.yml    | 54 ++++++++++++++++++++++++++++++-----------------
 ci/TestPatches.hs | 24 ++++++++++++++++++++-
 run-ci            |  4 ++++
 3 files changed, 62 insertions(+), 20 deletions(-)

diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 8ea85a23..04f05df4 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -14,6 +14,7 @@
 #
 
 stages:
+  - prebuild
   - test
   - update-repo
   - deploy
@@ -72,19 +73,11 @@ build-8.8:
     - branches
     - merge_requests
 
-.build:
-  stage: test
-
+.base:
   tags:
     - x86_64-linux
-
   image: nixos/nix
 
-  cache:
-    key: build-HEAD
-    paths:
-      - store.nar
-
   before_script:
     - |
       if [ -e store.nar ]; then
@@ -99,29 +92,52 @@ build-8.8:
     - echo "Bindist tarball is $GHC_TARBALL"
     - |
       nix build \
-      -f https://github.com/mpickering/ghc-artefact-nix/archive/master.tar.gz \
-      --argstr url $GHC_TARBALL \
-      --out-link ghc \
-      ghcHEAD
+        -f https://github.com/mpickering/ghc-artefact-nix/archive/master.tar.gz \
+        --argstr url $GHC_TARBALL \
+        --out-link ghc \
+        ghcHEAD
+    - export GHC=`pwd`/ghc/bin/ghc
+    - rm -Rf $HOME/.cabal/packages/local
     - export GHC=`pwd`/ghc/bin/ghc
-    - rm -Rf $HOME/.cabal/packages/local ci/run
       # Build CI executable
     - |
       nix-build ./ci -j$CPUS --no-build-output
       nix-store --export \
-        $(nix-store -qR --include-outputs \
-          $(nix-instantiate --quiet ./ci)) \
-      > store.nar
+        $(nix-store -qR $(nix-instantiate --quiet ./ci)) \
+        > store.nar
       # Test it
-    - nix run -f ./ci -c run-ci
+    - nix run -f ./ci -c run-ci $ARGS
+
+prebuild:
+  stage: prebuild
+  extends: .base
+  only:
+    - branches
+    - merge_requests
+  variables:
+    ARGS: "--only=acme-box"
+  artifacts:
+    paths:
+      - store.nar
+      - ci/run
+    expire_in: 1 day
+  cache:
+    key: build-HEAD
+    paths:
+      - store.nar
 
+.build:
+  stage: test
+  extends: .base
+  dependencies:
+    - prebuild
+  parallel: 5
   after_script:
     - ls -lh
     - |
       nix run -f ./ci -c \
       tar -cJf results.tar.xz -C ci/run \
       results.json logs
-
   artifacts:
     when: always
     paths:
diff --git a/ci/TestPatches.hs b/ci/TestPatches.hs
index e50fb3bb..fbfbfb69 100644
--- a/ci/TestPatches.hs
+++ b/ci/TestPatches.hs
@@ -53,6 +53,12 @@ newtype BrokenPackages = BrokenPackages { getBrokenPackageNames :: S.Set PkgName
 failureExpected :: BrokenPackages -> PkgName -> Bool
 failureExpected (BrokenPackages pkgs) name = name `S.member` pkgs
 
+-- | To facilitate splitting the builds across multiple GitLab CI jobs we
+-- support *build partitioning*, where the tested packages are split evenly
+-- into @n@ partitions. A 'BuildPartition' identifies one set in such a
+-- partitioning.
+data BuildPartition = BuildPartition { bpIndex, bpCount :: !Int }
+
 data Config = Config { configPatchDir :: FilePath
                      , configCompiler :: FilePath
                      , configGhcOptions :: [String]
@@ -62,6 +68,7 @@ data Config = Config { configPatchDir :: FilePath
                      , configExtraCabalFragments :: [FilePath]
                      , configExtraPackages :: [(Cabal.PackageName, Version)]
                      , configExpectedBrokenPkgs :: BrokenPackages
+                     , configBuildPartition :: Maybe BuildPartition
                      }
 
 cabalOptions :: Config -> [String]
@@ -82,6 +89,7 @@ config =
     <*> extraCabalFragments
     <*> extraPackages
     <*> expectedBrokenPkgs
+    <*> optional buildPartition
   where
     patchDir = option str (short 'p' <> long "patches" <> help "patch directory" <> value "./patches")
     compiler = option str (short 'w' <> long "with-compiler" <> help "path of compiler")
@@ -98,6 +106,13 @@ config =
       $ option
           (fmap toPkgName pkgName)
           (short 'b' <> long "expect-broken" <> metavar "PKGNAME" <> help "expect the given package to fail to build")
+    buildPartition =
+      f <$> option auto (long "partition" <> metavar "N" <> help "the build partition index")
+        <*> option auto (long "partition-count" <> metavar "N" <> help "the number of build partitions")
+      where
+        f i n
+          | i < n = BuildPartition i n
+          | otherwise = error "partition index must be less than partition count"
 
     pkgVer :: ReadM (Cabal.PackageName, Version)
     pkgVer = str >>= parse . T.pack
@@ -115,11 +130,18 @@ config =
     pkgName :: ReadM Cabal.PackageName
     pkgName = str >>= maybe (fail "invalid package name") pure . simpleParse
 
+takeBuildPartition :: BuildPartition -> [a] -> [a]
+takeBuildPartition (BuildPartition i n) xs =
+    take partSize $ drop (i * partSize) xs
+  where
+    partSize = length xs `div` n + 1
+
 testPatches :: Config -> IO ()
 testPatches cfg = do
   setup cfg
   packages <- findPatchedPackages (configPatchDir cfg)
-  packages <- return (packages ++ configExtraPackages cfg)
+  packages <- return $ maybe id takeBuildPartition (configBuildPartition cfg)
+                     $ (packages ++ configExtraPackages cfg)
   let packages' :: S.Set (Cabal.PackageName, Version)
       packages'
         | Just only <- configOnlyPackages cfg
diff --git a/run-ci b/run-ci
index 0a02add9..6936d055 100755
--- a/run-ci
+++ b/run-ci
@@ -26,6 +26,10 @@ if [ -n "$EXTRA_HC_OPTS" ]; then
   EXTRA_OPTS="$EXTRA_OPTS --ghc-option=\"$EXTRA_HC_OPTS\""
 fi
 
+if [ -n "$CI_NODE_INDEX" ]; then
+  EXTRA_OPTS="$EXTRA_OPTS --partition=$[$CI_NODE_INDEX-1] --partition-count=$CI_NODE_TOTAL"
+fi
+
 mkdir -p run
 
 echo "" > run/deps.cabal.project
-- 
GitLab