16573: add a deduplication-report command to arvados-client
[arvados.git] / lib / deduplicationreport / report.go
1 // Copyright (C) The Arvados Authors. All rights reserved.
2 //
3 // SPDX-License-Identifier: AGPL-3.0
4
5 package deduplicationreport
6
7 import (
8         "flag"
9         "fmt"
10         "io"
11         "strings"
12
13         "git.arvados.org/arvados.git/lib/config"
14         "git.arvados.org/arvados.git/sdk/go/arvados"
15         "git.arvados.org/arvados.git/sdk/go/arvadosclient"
16         "git.arvados.org/arvados.git/sdk/go/manifest"
17
18         "github.com/dustin/go-humanize"
19         "github.com/sirupsen/logrus"
20 )
21
22 func deDuplicate(inputs []string) (trimmed []string) {
23         seen := make(map[string]bool)
24         for _, uuid := range inputs {
25                 if _, ok := seen[uuid]; !ok {
26                         seen[uuid] = true
27                         trimmed = append(trimmed, uuid)
28                 }
29         }
30         return
31 }
32
33 func parseFlags(prog string, args []string, loader *config.Loader, logger *logrus.Logger, stderr io.Writer) (exitcode int, inputs []string) {
34         flags := flag.NewFlagSet("", flag.ContinueOnError)
35         flags.SetOutput(stderr)
36         flags.Usage = func() {
37                 fmt.Fprintf(flags.Output(), `
38 Usage:
39   %s [options ...] <collection-uuid> <collection-uuid> ...
40
41   %s [options ...] <collection-pdh>,<collection_uuid> \
42      <collection-pdh>,<collection_uuid> ...
43
44   This program analyzes the overlap in blocks used by 2 or more collections. It
45   prints a deduplication report that shows the nominal space used by the list
46   of collection, as well as the actual size and the amount of space that is
47   saved by Keep's deduplication.
48
49   The list of collections may be provided in two ways. A list of collection
50   uuids is sufficient. Alternatively, the PDH for each collection may also be
51   provided. This is will greatly speed up operation when the list contains
52   multiple collections with the same PDH.
53
54   Exit status will be zero if there were no errors generating the report.
55
56 Example:
57
58   Use the 'arv' and 'jq' commands to get the list of the 100
59   largest collections and generate the deduplication report:
60
61   arv collection list --order 'file_size_total desc' | \
62     jq -r '.items[] | [.portable_data_hash,.uuid] |@csv' | \
63     tail -n100 |sed -e 's/"//g'|tr '\n' ' ' | \
64     xargs %s
65
66 Options:
67 `, prog, prog, prog)
68                 flags.PrintDefaults()
69         }
70         loader.SetupFlags(flags)
71         loglevel := flags.String("log-level", "info", "logging level (debug, info, ...)")
72         err := flags.Parse(args)
73         if err == flag.ErrHelp {
74                 return 0, inputs
75         } else if err != nil {
76                 return 2, inputs
77         }
78
79         inputs = flags.Args()
80
81         inputs = deDuplicate(inputs)
82
83         if len(inputs) < 2 {
84                 logger.Error("Error: at least 2 different collections UUIDs required")
85                 flags.Usage()
86                 return 2, inputs
87         }
88
89         lvl, err := logrus.ParseLevel(*loglevel)
90         if err != nil {
91                 return 2, inputs
92         }
93         logger.SetLevel(lvl)
94         return
95 }
96
97 func blockList(collection arvados.Collection) (blocks map[string]int) {
98         blocks = make(map[string]int)
99         m := manifest.Manifest{Text: collection.ManifestText}
100         blockChannel := m.BlockIterWithDuplicates()
101         for b := range blockChannel {
102                 blocks[b.Digest.String()] = b.Size
103         }
104         return
105 }
106
107 func report(prog string, args []string, loader *config.Loader, logger *logrus.Logger, stdout, stderr io.Writer) (exitcode int) {
108
109         var inputs []string
110         exitcode, inputs = parseFlags(prog, args, loader, logger, stderr)
111         if exitcode != 0 {
112                 return
113         }
114
115         // Arvados Client setup
116         arv, err := arvadosclient.MakeArvadosClient()
117         if err != nil {
118                 logger.Errorf("error creating Arvados object: %s", err)
119                 exitcode = 1
120                 return
121         }
122
123         type Col struct {
124                 FileSizeTotal int64
125                 FileCount     int64
126         }
127
128         blocks := make(map[string]map[string]int)
129         pdhs := make(map[string]Col)
130         var nominalSize int64
131
132         fmt.Println()
133         for _, input := range inputs {
134                 var uuid string
135                 var pdh string
136                 if strings.Contains(input, ",") {
137                         // The input is in the format pdh,uuid. This will allow us to save time on duplicate pdh's
138                         tmp := strings.Split(input, ",")
139                         pdh = tmp[0]
140                         uuid = tmp[1]
141                 } else {
142                         // The input must be a plain uuid
143                         uuid = input
144                 }
145                 if !strings.Contains(uuid, "-4zz18-") {
146                         logger.Error("uuid must refer to collection object")
147                         exitcode = 1
148                         return
149                 }
150                 if _, ok := pdhs[pdh]; ok {
151                         // We've processed a collection with this pdh already. Simply add its
152                         // size to the totals and move on to the next one.
153                         // Note that we simply trust the PDH matches the collection UUID here,
154                         // in other words, we use it over the UUID. If they don't match, the report
155                         // will be wrong.
156                         nominalSize += pdhs[pdh].FileSizeTotal
157                 } else {
158                         var collection arvados.Collection
159                         err = arv.Get("collections", uuid, nil, &collection)
160                         if err != nil {
161                                 logger.Errorf("Error: unable to retrieve collection: %s\n", err)
162                                 exitcode = 1
163                                 return
164                         }
165                         blocks[uuid] = make(map[string]int)
166                         blocks[uuid] = blockList(collection)
167                         if pdh != "" && collection.PortableDataHash != pdh {
168                                 logger.Errorf("Error: the collection with UUID %s has PDH %s, but a different PDH was provided in the arguments: %s\n", uuid, collection.PortableDataHash, pdh)
169                                 exitcode = 1
170                                 return
171                         }
172                         if pdh == "" {
173                                 pdh = collection.PortableDataHash
174                         }
175
176                         col := Col{}
177                         if collection.FileSizeTotal != 0 || collection.FileCount != 0 {
178                                 nominalSize += collection.FileSizeTotal
179                                 col.FileSizeTotal = collection.FileSizeTotal
180                                 col.FileCount = int64(collection.FileCount)
181                         } else {
182                                 // Collections created with old Arvados versions do not always have the total file size and count cached in the collections object
183                                 var collSize int64
184                                 for _, size := range blocks[uuid] {
185                                         collSize += int64(size)
186                                 }
187                                 nominalSize += collSize
188                                 col.FileSizeTotal = collSize
189                         }
190                         pdhs[pdh] = col
191                 }
192
193                 if pdhs[pdh].FileCount != 0 {
194                         fmt.Fprintf(stdout, "Collection %s: pdh %s; nominal size %d (%s); file count %d\n", uuid, pdh, pdhs[pdh].FileSizeTotal, humanize.IBytes(uint64(pdhs[pdh].FileSizeTotal)), pdhs[pdh].FileCount)
195                 } else {
196                         fmt.Fprintf(stdout, "Collection %s: pdh %s; nominal size %d (%s)\n", uuid, pdh, pdhs[pdh].FileSizeTotal, humanize.IBytes(uint64(pdhs[pdh].FileSizeTotal)))
197                 }
198         }
199
200         var totalSize int64
201         seen := make(map[string]bool)
202         for _, v := range blocks {
203                 for pdh, size := range v {
204                         if _, ok := seen[pdh]; !ok {
205                                 seen[pdh] = true
206                                 totalSize += int64(size)
207                         }
208                 }
209         }
210         fmt.Fprintln(stdout)
211         fmt.Fprintf(stdout, "Collections:                 %15d\n", len(inputs))
212         fmt.Fprintf(stdout, "Nominal size of stored data: %15d bytes (%s)\n", nominalSize, humanize.IBytes(uint64(nominalSize)))
213         fmt.Fprintf(stdout, "Actual size of stored data:  %15d bytes (%s)\n", totalSize, humanize.IBytes(uint64(totalSize)))
214         fmt.Fprintf(stdout, "Saved by Keep deduplication: %15d bytes (%s)\n", nominalSize-totalSize, humanize.IBytes(uint64(nominalSize-totalSize)))
215
216         return exitcode
217 }