go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/led/job/edit_dimensions.go (about) 1 // Copyright 2020 The LUCI Authors. 2 // 3 // Licensed under the Apache License, Version 2.0 (the "License"); 4 // you may not use this file except in compliance with the License. 5 // You may obtain a copy of the License at 6 // 7 // http://www.apache.org/licenses/LICENSE-2.0 8 // 9 // Unless required by applicable law or agreed to in writing, software 10 // distributed under the License is distributed on an "AS IS" BASIS, 11 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 // See the License for the specific language governing permissions and 13 // limitations under the License. 14 15 package job 16 17 import ( 18 fmt "fmt" 19 "sort" 20 "strconv" 21 "strings" 22 "time" 23 24 "github.com/golang/protobuf/ptypes" 25 durpb "google.golang.org/protobuf/types/known/durationpb" 26 27 "go.chromium.org/luci/common/data/stringset" 28 "go.chromium.org/luci/common/errors" 29 ) 30 31 // ExpiringValue represents a tuple of dimension value, plus an expiration time. 32 // 33 // If Expiration is zero, it counts as "no expiration". 34 type ExpiringValue struct { 35 Value string 36 Expiration time.Duration 37 } 38 39 // DimensionEditCommand is instruction on how to process the values in the task 40 // associated with a swarming dimension. 41 // 42 // The fields are processed in order: 43 // - if SetValues is non-nil, the dimension values are set to this set 44 // (including empty). 45 // - if RemoveValues is non-empty, these values will be removed from the 46 // dimension values. 47 // - if AddValues is non-empty, these values will ber added to the dimension 48 // values. 49 // 50 // If the set of values at the end of this process is empty, the dimension will 51 // be removed from the task. Otherwise the dimension will be set to the sorted 52 // remaining values. 53 type DimensionEditCommand struct { 54 SetValues []ExpiringValue 55 RemoveValues []string 56 AddValues []ExpiringValue 57 } 58 59 // DimensionEditCommands is a mapping of dimension name to a set of commands to 60 // apply to the values of that dimension. 61 type DimensionEditCommands map[string]*DimensionEditCommand 62 63 func split2(s, sep string) (a, b string, ok bool) { 64 idx := strings.Index(s, sep) 65 if idx == -1 { 66 return s, "", false 67 } 68 return s[:idx], s[idx+1:], true 69 } 70 71 func rsplit2(s, sep string) (a, b string) { 72 idx := strings.LastIndex(s, sep) 73 if idx == -1 { 74 return s, "" 75 } 76 return s[:idx], s[idx+1:] 77 } 78 79 func parseDimensionEditCmd(cmd string) (dim, op, val string, exp time.Duration, err error) { 80 dim, valueExp, ok := split2(cmd, "=") 81 if !ok { 82 err = errors.Reason("expected $key$op$value, but op was missing").Err() 83 return 84 } 85 86 switch dim[len(dim)-1] { 87 case '-': 88 op = "-=" 89 dim = dim[:len(dim)-1] 90 case '+': 91 op = "+=" 92 dim = dim[:len(dim)-1] 93 default: 94 op = "=" 95 } 96 97 val, expStr := rsplit2(valueExp, "@") 98 if expStr != "" { 99 var expSec int 100 if expSec, err = strconv.Atoi(expStr); err != nil { 101 err = errors.Annotate(err, "parsing expiration %q", expStr).Err() 102 return 103 } 104 exp = time.Second * time.Duration(expSec) 105 } 106 107 if val == "" && op != "=" { 108 err = errors.Reason("empty value not allowed for operator %q: %q", op, cmd).Err() 109 } 110 if exp != 0 && op == "-=" { 111 err = errors.Reason("expiration seconds not allowed for operator %q: %q", op, cmd).Err() 112 } 113 114 return 115 } 116 117 // MakeDimensionEditCommands takes a slice of commands in the form of: 118 // 119 // dimension= 120 // dimension=value 121 // dimension=value@1234 122 // 123 // dimension-=value 124 // 125 // dimension+=value 126 // dimension+=value@1234 127 // 128 // Logically: 129 // - dimension_name - The name of the dimension to modify 130 // - operator 131 // - "=" - Add value to SetValues. If empty, ensures that SetValues is 132 // non-nil (i.e. clear all values for this dimension). 133 // - "-=" - Add value to RemoveValues. 134 // - "+=" - Add value to AddValues. 135 // - value - The dimension value for the operand 136 // - expiration seconds - The time at which this value should expire. 137 // 138 // All equivalent operations for the same dimension will be grouped into 139 // a single DimensionEditCommand in the order they appear in `commands`. 140 func MakeDimensionEditCommands(commands []string) (DimensionEditCommands, error) { 141 if len(commands) == 0 { 142 return nil, nil 143 } 144 145 ret := DimensionEditCommands{} 146 for _, command := range commands { 147 dimension, operator, value, expiration, err := parseDimensionEditCmd(command) 148 if err != nil { 149 return nil, errors.Annotate(err, "parsing %q", command).Err() 150 } 151 editCmd := ret[dimension] 152 if editCmd == nil { 153 editCmd = &DimensionEditCommand{} 154 ret[dimension] = editCmd 155 } 156 switch operator { 157 case "=": 158 // explicitly setting SetValues takes care of the 'dimension=' case. 159 if editCmd.SetValues == nil { 160 editCmd.SetValues = []ExpiringValue{} 161 } 162 if value != "" { 163 editCmd.SetValues = append(editCmd.SetValues, ExpiringValue{ 164 Value: value, Expiration: expiration, 165 }) 166 } 167 case "-=": 168 editCmd.RemoveValues = append(editCmd.RemoveValues, value) 169 case "+=": 170 editCmd.AddValues = append(editCmd.AddValues, ExpiringValue{ 171 Value: value, Expiration: expiration, 172 }) 173 } 174 } 175 return ret, nil 176 } 177 178 // Applies the DimensionEditCommands to the given logicalDimensions. 179 func (dimEdits DimensionEditCommands) apply(dimMap logicalDimensions, minExp time.Duration) { 180 if len(dimEdits) == 0 { 181 return 182 } 183 184 shouldApply := func(eVal ExpiringValue) bool { 185 return eVal.Expiration == 0 || minExp == 0 || eVal.Expiration >= minExp 186 } 187 188 for dim, edits := range dimEdits { 189 if edits.SetValues != nil { 190 dimMap[dim] = make(dimValueExpiration, len(edits.SetValues)) 191 for _, expVal := range edits.SetValues { 192 if shouldApply(expVal) { 193 dimMap[dim][expVal.Value] = expVal.Expiration 194 } 195 } 196 } 197 for _, value := range edits.RemoveValues { 198 delete(dimMap[dim], value) 199 } 200 for _, expVal := range edits.AddValues { 201 if shouldApply(expVal) { 202 expValMap := dimMap[dim] 203 if expValMap == nil { 204 expValMap = dimValueExpiration{} 205 dimMap[dim] = expValMap 206 } 207 expValMap[expVal.Value] = expVal.Expiration 208 } 209 } 210 } 211 toRemove := stringset.New(len(dimMap)) 212 for dim, valExps := range dimMap { 213 if len(valExps) == 0 { 214 toRemove.Add(dim) 215 } 216 } 217 toRemove.Iter(func(dim string) bool { 218 delete(dimMap, dim) 219 return true 220 }) 221 } 222 223 // ExpiringDimensions is a map from dimension name to a list of values 224 // corresponding to that dimension. 225 // 226 // When retrieved from a led library, the values will be sorted by expiration 227 // time, followed by value. Expirations of 0 (i.e. "infinite") are sorted last. 228 type ExpiringDimensions map[string][]ExpiringValue 229 230 func (e ExpiringDimensions) String() string { 231 bits := []string{} 232 for key, values := range e { 233 for _, value := range values { 234 if value.Expiration == 0 { 235 bits = append(bits, fmt.Sprintf("%s=%s", key, value.Value)) 236 } else { 237 bits = append(bits, fmt.Sprintf( 238 "%s=%s@%d", key, value.Value, value.Expiration/time.Second)) 239 } 240 } 241 } 242 return strings.Join(bits, ", ") 243 } 244 245 func (e ExpiringDimensions) toLogical() logicalDimensions { 246 ret := logicalDimensions{} 247 for key, expVals := range e { 248 dve := ret[key] 249 if dve == nil { 250 dve = dimValueExpiration{} 251 ret[key] = dve 252 } 253 for _, expVal := range expVals { 254 dve[expVal.Value] = expVal.Expiration 255 } 256 } 257 return ret 258 } 259 260 type dimValueExpiration map[string]time.Duration 261 262 func (valExps dimValueExpiration) toSlice() []string { 263 ret := make([]string, 0, len(valExps)) 264 for value := range valExps { 265 ret = append(ret, value) 266 } 267 sort.Strings(ret) 268 return ret 269 } 270 271 // A multimap of dimension to values. 272 type logicalDimensions map[string]dimValueExpiration 273 274 func (dims logicalDimensions) update(dim, value string, expiration *durpb.Duration) { 275 var exp time.Duration 276 if expiration != nil { 277 var err error 278 if exp, err = ptypes.Duration(expiration); err != nil { 279 panic(err) 280 } 281 } 282 dims.updateDuration(dim, value, exp) 283 } 284 285 func (dims logicalDimensions) updateDuration(dim, value string, exp time.Duration) { 286 if dims[dim] == nil { 287 dims[dim] = dimValueExpiration{} 288 } 289 dims[dim][value] = exp 290 } 291 292 func expLess(a, b time.Duration) bool { 293 if a == 0 { // b is either infinity (==a) or finite (<a) 294 return false 295 } else if b == 0 { // b is infinity, a is finite 296 return true 297 } 298 return a < b 299 } 300 301 func (dims logicalDimensions) toExpiringDimensions() ExpiringDimensions { 302 ret := ExpiringDimensions{} 303 for key, dve := range dims { 304 for dim, expiration := range dve { 305 ret[key] = append(ret[key], ExpiringValue{dim, expiration}) 306 } 307 sort.Slice(ret[key], func(i, j int) bool { 308 a, b := ret[key][i], ret[key][j] 309 if a.Expiration == b.Expiration { 310 return a.Value < b.Value 311 } 312 return expLess(a.Expiration, b.Expiration) 313 }) 314 } 315 return ret 316 }