blob: c2ae71aa189482de26d0eeaff565e9a735b68342 [file] [log] [blame]
package tiling
import (
"fmt"
"strings"
"go.skia.org/infra/go/paramtools"
"go.skia.org/infra/go/query"
"go.skia.org/infra/go/skerr"
"go.skia.org/infra/go/sklog"
"go.skia.org/infra/go/util"
"go.skia.org/infra/golden/go/types"
)
// Trace represents all the Digests of a single test across a series
// of Commits.
type Trace struct {
// Digests represents the images seen over the last N commits. Index 0 is the oldest data, index
// len-1 is the newest data.
Digests []types.Digest
// keys describe how the digest was produced. These keys and values contribute to the trace id
// (that is, they contribute to uniqueness).
keys map[string]string
// options describe other parameters. These do not contribute to the trace id, but are searchable.
// It is strongly recommended to not have the same string be a key in both keys and options.
// Doing so could lead to unspecified behavior.
options map[string]string
// OptionsID is the md5 hash of the options map.
OptionsID []byte
// cache these values so as not to incur the non-zero map lookup cost (~15 ns) repeatedly.
testName types.TestName
corpus string
}
// NewEmptyTrace allocates a new Trace set up for the given number of samples.
//
// The Trace Digests are pre-filled in with the missing data sentinel since not
// all tests will be run on all commits.
func NewEmptyTrace(numDigests int, keys, options map[string]string) *Trace {
g := &Trace{
Digests: make([]types.Digest, numDigests),
keys: keys,
options: options,
// Prefetch these now, while we have the chance.
testName: types.TestName(keys[types.PrimaryKeyField]),
corpus: keys[types.CorpusField],
}
for i := range g.Digests {
g.Digests[i] = MissingDigest
}
return g
}
// NewTrace creates a new Trace with the given data.
func NewTrace(digests []types.Digest, keys, options map[string]string) *Trace {
return &Trace{
Digests: digests,
keys: keys,
options: options,
// Prefetch these now, while we have the chance.
testName: types.TestName(keys[types.PrimaryKeyField]),
corpus: keys[types.CorpusField],
}
}
// Keys returns the key value pairs associated with this trace.
func (t *Trace) Keys() map[string]string {
return t.keys
}
// Options returns the optional key value pairs associated with this trace (those that don't affect
// the trace id).
func (t *Trace) Options() map[string]string {
return t.options
}
// KeysAndOptions returns a combined copy of Keys() and Options().
func (t *Trace) KeysAndOptions() map[string]string {
m := make(map[string]string, len(t.keys)+len(t.options))
for k, v := range t.keys {
m[k] = v
}
for k, v := range t.options {
m[k] = v
}
return m
}
// Matches returns true if this trace matches the given ParamSet. To match, the trace must have
// each of the keys defined in the ParamSet in either the Keys() or the Options() and the associated
// value for each of those keys must be in the values from the ParamSet.
func (t *Trace) Matches(ps paramtools.ParamSet) bool {
for k, values := range ps {
if p, ok := t.keys[k]; ok && util.In(p, values) {
continue
}
if len(t.options) > 0 {
if p, ok := t.options[k]; ok && util.In(p, values) {
continue
}
}
// Not in keys nor options.
return false
}
return true
}
// TestName is a helper for extracting just the test name for this
// trace, of which there should always be exactly one.
func (t *Trace) TestName() types.TestName {
if t.testName == "" {
t.testName = types.TestName(t.keys[types.PrimaryKeyField])
}
return t.testName
}
// Corpus is a helper for extracting just the corpus key for this
// trace, of which there should always be exactly one.
func (t *Trace) Corpus() string {
if t.corpus == "" {
t.corpus = t.keys[types.CorpusField]
}
return t.corpus
}
// Len returns how many digests are in this trace.
func (t *Trace) Len() int {
return len(t.Digests)
}
// IsMissing implements the tiling.Trace interface.
func (t *Trace) IsMissing(i int) bool {
return t.Digests[i] == MissingDigest
}
// Merge returns a new Trace that has the digests joined together and the keys and options
// combined (with the other Trace's values overriding this trace's values in the event of a
// collision).
func (t *Trace) Merge(other *Trace) *Trace {
n := len(t.Digests) + len(other.Digests)
merged := NewEmptyTrace(n, copyStringMap(t.keys), copyStringMap(t.options))
for k, v := range other.keys {
merged.keys[k] = v
}
for k, v := range other.options {
merged.options[k] = v
}
// Combine the digests
copy(merged.Digests, t.Digests)
for i, v := range other.Digests {
merged.Digests[len(t.Digests)+i] = v
}
return merged
}
// copyStringMap returns a copy of the given string map.
func copyStringMap(m map[string]string) map[string]string {
c := make(map[string]string, len(m))
for k, v := range m {
c[k] = v
}
return c
}
// FillType is how filling in of missing values should be done in Trace.Grow().
type FillType int
const (
FillBefore FillType = iota
FillAfter
)
// Grow implements the tiling.Trace interface.
func (t *Trace) Grow(n int, fill FillType) {
if n < len(t.Digests) {
panic(fmt.Sprintf("Grow must take a value (%d) larger than the current Trace size: %d", n, len(t.Digests)))
}
delta := n - len(t.Digests)
newDigests := make([]types.Digest, n)
if fill == FillAfter {
copy(newDigests, t.Digests)
for i := 0; i < delta; i++ {
newDigests[i+len(t.Digests)] = MissingDigest
}
} else {
for i := 0; i < delta; i++ {
newDigests[i] = MissingDigest
}
copy(newDigests[delta:], t.Digests)
}
t.Digests = newDigests
}
// AtHead returns the last digest in the trace (HEAD) or the empty string otherwise.
func (t *Trace) AtHead() types.Digest {
if idx := t.LastIndex(); idx >= 0 {
return t.Digests[idx]
}
return MissingDigest
}
// LastIndex returns the index of last non-empty value in this trace and -1 if
// if the entire trace is empty.
func (t *Trace) LastIndex() int {
for i := len(t.Digests) - 1; i >= 0; i-- {
if t.Digests[i] != MissingDigest {
return i
}
}
return -1
}
// String prints a human friendly version of this trace.
func (t *Trace) String() string {
return fmt.Sprintf("Keys: %#v, Options: %#v, Digests: %q", t.keys, t.options, t.Digests)
}
// TraceIDFromParams deterministically returns a TraceID that uniquely encodes
// the given params. It follows the same convention as perf's trace ids, that
// is something like ",key1=value1,key2=value2,...," where the keys
// are in alphabetical order.
func TraceIDFromParams(params paramtools.Params) TraceID {
// Clean up any params with , or =
params = forceValid(params)
s, err := query.MakeKeyFast(params)
if err != nil {
// this should never happen given that forceValid fixes them up.
sklog.Warningf("Invalid params passed in for trace id %#v: %s", params, err)
return "invalid_trace_id"
}
return TraceID(s)
}
// ParseTraceID parses the output of TraceIDFromParams into Params
func ParseTraceID(id string) (paramtools.Params, error) {
prelimKeys, err := query.ParseKeyFast(id)
if err != nil {
return nil, skerr.Wrap(err)
}
traceKeys := make(map[string]string, len(prelimKeys))
for k, v := range prelimKeys {
k = strings.ReplaceAll(k, urlEncodedComma, ",")
v = strings.ReplaceAll(v, urlEncodedComma, ",")
traceKeys[k] = v
}
return traceKeys, nil
}
const urlEncodedComma = "%2C"
// clean replaces any special runes (',', '=') in a string such that
// they can be turned into a trace id, which uses those special runes
// as dividers.
func clean(s string) string {
// In most cases, traces will be valid, so check that first.
// Allocating the string buffer and copying the runes can be expensive
// when done for no reason.
bad := false
for _, c := range s {
if c == ',' || c == '=' {
bad = true
break
}
}
if !bad {
return s
}
sb := strings.Builder{}
sb.Grow(len(s))
// Regexp doesn't handle being run from a large number of go routines
// very well. See https://github.com/golang/go/issues/8232.
for _, c := range s {
if c == ',' {
// Some valid trace keys or values have commas, so we use a url-encoded special value.
sb.WriteString(urlEncodedComma)
} else if c == '=' {
sb.WriteRune('_')
} else {
sb.WriteRune(c)
}
}
return sb.String()
}
// forceValid ensures that the resulting map will make a valid structured key.
func forceValid(m map[string]string) map[string]string {
ret := make(map[string]string, len(m))
for key, value := range m {
ret[clean(key)] = clean(value)
}
return ret
}