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  }