go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/resultdb/internal/invocations/graph/graph_test.go (about)

     1  // Copyright 2022 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 graph
    16  
    17  import (
    18  	"fmt"
    19  	"strconv"
    20  	"strings"
    21  	"testing"
    22  
    23  	"cloud.google.com/go/spanner"
    24  	"github.com/gomodule/redigo/redis"
    25  
    26  	"go.chromium.org/luci/resultdb/internal/invocations"
    27  	"go.chromium.org/luci/resultdb/internal/spanutil"
    28  	"go.chromium.org/luci/resultdb/internal/testutil"
    29  	"go.chromium.org/luci/resultdb/internal/testutil/insert"
    30  	"go.chromium.org/luci/resultdb/pbutil"
    31  	pb "go.chromium.org/luci/resultdb/proto/v1"
    32  	"go.chromium.org/luci/server/redisconn"
    33  	"go.chromium.org/luci/server/span"
    34  
    35  	. "github.com/smartystreets/goconvey/convey"
    36  	. "go.chromium.org/luci/common/testing/assertions"
    37  )
    38  
    39  func TestReachable(t *testing.T) {
    40  	Convey(`Reachable`, t, func() {
    41  		ctx := testutil.SpannerTestContext(t)
    42  
    43  		read := func(roots ...invocations.ID) (ReachableInvocations, error) {
    44  			ctx, cancel := span.ReadOnlyTransaction(ctx)
    45  			defer cancel()
    46  			return Reachable(ctx, invocations.NewIDSet(roots...))
    47  		}
    48  
    49  		mustRead := func(roots ...invocations.ID) ReachableInvocations {
    50  			invs, err := read(roots...)
    51  			So(err, ShouldBeNil)
    52  			return invs
    53  		}
    54  
    55  		withInheritSources := map[string]any{
    56  			"InheritSources": true,
    57  		}
    58  		sources := func(number int) *pb.Sources {
    59  			return testutil.TestSourcesWithChangelistNumbers(number)
    60  		}
    61  		withSources := func(number int) map[string]any {
    62  			return map[string]any{
    63  				"Sources": spanutil.Compress(pbutil.MustMarshal(sources(number))),
    64  			}
    65  		}
    66  
    67  		Convey(`a -> []`, func() {
    68  			expected := ReachableInvocations{
    69  				Invocations: map[invocations.ID]ReachableInvocation{
    70  					"a": {
    71  						Realm: insert.TestRealm,
    72  					},
    73  				},
    74  				Sources: make(map[SourceHash]*pb.Sources),
    75  			}
    76  			Convey(`Root has no sources`, func() {
    77  				testutil.MustApply(ctx, node("a", nil)...)
    78  
    79  				So(mustRead("a"), ShouldResembleReachable, expected)
    80  			})
    81  			Convey(`Root has inherit sources`, func() {
    82  				testutil.MustApply(ctx, node("a", withInheritSources)...)
    83  
    84  				So(mustRead("a"), ShouldResembleReachable, expected)
    85  			})
    86  			Convey(`Root has concrete sources`, func() {
    87  				testutil.MustApply(ctx, node("a", withSources(1))...)
    88  
    89  				expected.Invocations["a"] = ReachableInvocation{
    90  					Realm:      insert.TestRealm,
    91  					SourceHash: HashSources(sources(1)),
    92  				}
    93  				expected.Sources[HashSources(sources(1))] = sources(1)
    94  
    95  				So(mustRead("a"), ShouldResembleReachable, expected)
    96  			})
    97  		})
    98  
    99  		Convey(`a -> [b, c]`, func() {
   100  			testutil.MustApply(ctx, testutil.CombineMutations(
   101  				node("a", withSources(1), "b", "c"),
   102  				node("b", withInheritSources),
   103  				node("c", nil),
   104  				insert.TestExonerations("a", "Z", nil, pb.ExonerationReason_OCCURS_ON_OTHER_CLS),
   105  				insert.TestResults("c", "Z", nil, pb.TestStatus_PASS, pb.TestStatus_FAIL),
   106  				insert.TestExonerations("c", "Z", nil, pb.ExonerationReason_NOT_CRITICAL),
   107  			)...)
   108  
   109  			expected := ReachableInvocations{
   110  				Invocations: map[invocations.ID]ReachableInvocation{
   111  					"a": {
   112  						HasTestExonerations: true,
   113  						Realm:               insert.TestRealm,
   114  						SourceHash:          HashSources(sources(1)),
   115  					},
   116  					"b": {
   117  						Realm:      insert.TestRealm,
   118  						SourceHash: HashSources(sources(1)),
   119  					},
   120  					"c": {
   121  						HasTestResults:      true,
   122  						HasTestExonerations: true,
   123  						Realm:               insert.TestRealm,
   124  					},
   125  				},
   126  				Sources: map[SourceHash]*pb.Sources{
   127  					HashSources(sources(1)): sources(1),
   128  				},
   129  			}
   130  
   131  			So(mustRead("a"), ShouldResembleReachable, expected)
   132  		})
   133  
   134  		Convey(`a -> b -> c`, func() {
   135  			testutil.MustApply(ctx, testutil.CombineMutations(
   136  				node("a", withSources(1), "b"),
   137  				node("b", withInheritSources, "c"),
   138  				node("c", withInheritSources),
   139  				insert.TestExonerations("a", "Z", nil, pb.ExonerationReason_OCCURS_ON_OTHER_CLS),
   140  				insert.TestResults("c", "Z", nil, pb.TestStatus_PASS, pb.TestStatus_FAIL),
   141  				insert.TestExonerations("c", "Z", nil, pb.ExonerationReason_NOT_CRITICAL),
   142  			)...)
   143  			expected := ReachableInvocations{
   144  				Invocations: map[invocations.ID]ReachableInvocation{
   145  					"a": {
   146  						HasTestExonerations: true,
   147  						Realm:               insert.TestRealm,
   148  						SourceHash:          HashSources(sources(1)),
   149  					},
   150  					"b": {
   151  						Realm:      insert.TestRealm,
   152  						SourceHash: HashSources(sources(1)),
   153  					},
   154  					"c": {
   155  						HasTestResults:      true,
   156  						HasTestExonerations: true,
   157  						Realm:               insert.TestRealm,
   158  						SourceHash:          HashSources(sources(1)),
   159  					},
   160  				},
   161  				Sources: map[SourceHash]*pb.Sources{
   162  					HashSources(sources(1)): sources(1),
   163  				},
   164  			}
   165  
   166  			So(mustRead("a"), ShouldResembleReachable, expected)
   167  		})
   168  
   169  		Convey(`a -> [b1 -> b2, c, d] -> e`, func() {
   170  			// e is included through three paths:
   171  			// a -> b1 -> b2 -> e
   172  			// a -> c -> e
   173  			// a -> d -> e
   174  			//
   175  			// As e is set to inherit sources, the sources
   176  			// resolved for e shall be from one of these three
   177  			// paths. In practice we advise clients not to
   178  			// use multiple inclusion paths like this.
   179  			//
   180  			// We test here that we behave deterministically,
   181  			// selecting the invocation to inherit based on:
   182  			// 1. Shortest path to the root, then
   183  			// 2. Minimal invocation name.
   184  			// In this case, e should inherit sources from c.
   185  			testutil.MustApply(ctx, testutil.CombineMutations(
   186  				node("a", withSources(1), "b1", "c", "d"),
   187  				node("b1", withInheritSources, "b2"),
   188  				node("b2", withInheritSources, "e"),
   189  				node("c", withSources(2), "e"),
   190  				node("d", withSources(3), "e"),
   191  				node("e", withInheritSources),
   192  			)...)
   193  
   194  			expected := ReachableInvocations{
   195  				Invocations: map[invocations.ID]ReachableInvocation{
   196  					"a": {
   197  						Realm:      insert.TestRealm,
   198  						SourceHash: HashSources(sources(1)),
   199  					},
   200  					"b1": {
   201  						Realm:      insert.TestRealm,
   202  						SourceHash: HashSources(sources(1)),
   203  					},
   204  					"b2": {
   205  						Realm:      insert.TestRealm,
   206  						SourceHash: HashSources(sources(1)),
   207  					},
   208  					"c": {
   209  						Realm:      insert.TestRealm,
   210  						SourceHash: HashSources(sources(2)),
   211  					},
   212  					"d": {
   213  						Realm:      insert.TestRealm,
   214  						SourceHash: HashSources(sources(3)),
   215  					},
   216  					"e": {
   217  						Realm:      insert.TestRealm,
   218  						SourceHash: HashSources(sources(2)),
   219  					},
   220  				},
   221  				Sources: map[SourceHash]*pb.Sources{
   222  					HashSources(sources(1)): sources(1),
   223  					HashSources(sources(2)): sources(2),
   224  					HashSources(sources(3)): sources(3),
   225  				},
   226  			}
   227  
   228  			So(mustRead("a"), ShouldResembleReachable, expected)
   229  		})
   230  		Convey(`a -> b -> a`, func() {
   231  			// Test a graph with cycles to make sure
   232  			// source resolution always terminates.
   233  			testutil.MustApply(ctx, testutil.CombineMutations(
   234  				node("a", withInheritSources, "b"),
   235  				node("b", withInheritSources, "a"),
   236  			)...)
   237  
   238  			expected := ReachableInvocations{
   239  				Invocations: map[invocations.ID]ReachableInvocation{
   240  					"a": {
   241  						Realm: insert.TestRealm,
   242  					},
   243  					"b": {
   244  						Realm: insert.TestRealm,
   245  					},
   246  				},
   247  				Sources: map[SourceHash]*pb.Sources{},
   248  			}
   249  
   250  			So(mustRead("a"), ShouldResembleReachable, expected)
   251  		})
   252  
   253  		Convey(`a -> [100 invocations]`, func() {
   254  			nodes := [][]*spanner.Mutation{}
   255  			nodeSet := []invocations.ID{}
   256  			for i := 0; i < 100; i++ {
   257  				name := invocations.ID("b" + strconv.FormatInt(int64(i), 10))
   258  				nodes = append(nodes, node(name, nil))
   259  				nodes = append(nodes, insert.TestResults(string(name), "testID", nil, pb.TestStatus_SKIP))
   260  				nodes = append(nodes, insert.TestExonerations(name, "testID", nil, pb.ExonerationReason_NOT_CRITICAL))
   261  				nodeSet = append(nodeSet, name)
   262  			}
   263  			nodes = append(nodes, node("a", nil, nodeSet...))
   264  			testutil.MustApply(ctx, testutil.CombineMutations(
   265  				nodes...,
   266  			)...)
   267  			expectedInvs := NewReachableInvocations()
   268  			expectedInvs.Invocations["a"] = ReachableInvocation{
   269  				Realm: insert.TestRealm,
   270  			}
   271  			for _, id := range nodeSet {
   272  				expectedInvs.Invocations[id] = ReachableInvocation{
   273  					HasTestResults:      true,
   274  					HasTestExonerations: true,
   275  					Realm:               insert.TestRealm,
   276  				}
   277  			}
   278  			So(mustRead("a"), ShouldResembleReachable, expectedInvs)
   279  		})
   280  	})
   281  }
   282  
   283  func node(id invocations.ID, extraValues map[string]any, included ...invocations.ID) []*spanner.Mutation {
   284  	return insert.InvocationWithInclusions(id, pb.Invocation_ACTIVE, extraValues, included...)
   285  }
   286  
   287  // BenchmarkChainFetch measures performance of a fetching a graph
   288  // with a 10 linear inclusions.
   289  func BenchmarkChainFetch(b *testing.B) {
   290  	ctx := testutil.SpannerTestContext(b)
   291  
   292  	var ms []*spanner.Mutation
   293  	var prev invocations.ID
   294  	for i := 0; i < 10; i++ {
   295  		var included []invocations.ID
   296  		if prev != "" {
   297  			included = append(included, prev)
   298  		}
   299  		id := invocations.ID(fmt.Sprintf("inv%d", i))
   300  		prev = id
   301  		ms = append(ms, node(id, nil, included...)...)
   302  	}
   303  
   304  	if _, err := span.Apply(ctx, ms); err != nil {
   305  		b.Fatal(err)
   306  	}
   307  
   308  	read := func() {
   309  		ctx, cancel := span.ReadOnlyTransaction(ctx)
   310  		defer cancel()
   311  
   312  		if _, err := Reachable(ctx, invocations.NewIDSet(prev)); err != nil {
   313  			b.Fatal(err)
   314  		}
   315  	}
   316  
   317  	// Run fetch a few times before starting measuring.
   318  	for i := 0; i < 5; i++ {
   319  		read()
   320  	}
   321  
   322  	b.StartTimer()
   323  	for i := 0; i < b.N; i++ {
   324  		read()
   325  	}
   326  }
   327  
   328  type redisConn struct {
   329  	redis.Conn
   330  	reply    any
   331  	replyErr error
   332  	received [][]any
   333  }
   334  
   335  func (c *redisConn) Send(cmd string, args ...any) error {
   336  	c.received = append(c.received, append([]any{cmd}, args...))
   337  	return nil
   338  }
   339  
   340  func (c *redisConn) Do(cmd string, args ...any) (reply any, err error) {
   341  	if cmd != "" {
   342  		So(c.Send(cmd, args...), ShouldBeNil)
   343  	}
   344  	return c.reply, c.replyErr
   345  }
   346  
   347  func (c *redisConn) Err() error { return nil }
   348  
   349  func (c *redisConn) Close() error { return nil }
   350  
   351  func TestReachCache(t *testing.T) {
   352  	t.Parallel()
   353  
   354  	Convey(`TestReachCache`, t, func(c C) {
   355  		ctx := testutil.TestingContext()
   356  
   357  		// Stub Redis.
   358  		conn := &redisConn{}
   359  		ctx = redisconn.UsePool(ctx, &redis.Pool{
   360  			Dial: func() (redis.Conn, error) {
   361  				return conn, nil
   362  			},
   363  		})
   364  
   365  		cache := reachCache("inv")
   366  
   367  		invs := NewReachableInvocations()
   368  
   369  		source1 := &pb.Sources{
   370  			GitilesCommit: &pb.GitilesCommit{
   371  				Host:       "myproject.googlesource.com",
   372  				Project:    "myproject/src",
   373  				Ref:        "refs/heads/main",
   374  				CommitHash: strings.Repeat("a", 40),
   375  				Position:   105,
   376  			},
   377  		}
   378  		invs.Sources[HashSources(source1)] = source1
   379  
   380  		invs.Invocations["inv"] = ReachableInvocation{
   381  			HasTestResults:      true,
   382  			HasTestExonerations: true,
   383  			Realm:               insert.TestRealm,
   384  		}
   385  		invs.Invocations["a"] = ReachableInvocation{
   386  			HasTestResults: true,
   387  			Realm:          insert.TestRealm,
   388  			SourceHash:     HashSources(source1),
   389  		}
   390  		invs.Invocations["b"] = ReachableInvocation{
   391  			HasTestExonerations: true,
   392  			Realm:               insert.TestRealm,
   393  		}
   394  
   395  		Convey(`Read`, func() {
   396  			var err error
   397  			conn.reply, err = invs.marshal()
   398  			So(err, ShouldBeNil)
   399  			actual, err := cache.Read(ctx)
   400  			So(err, ShouldBeNil)
   401  			So(actual, ShouldResemble, invs)
   402  			So(conn.received, ShouldResemble, [][]any{
   403  				{"GET", "reach4:inv"},
   404  			})
   405  		})
   406  
   407  		Convey(`Read, cache miss`, func() {
   408  			conn.replyErr = redis.ErrNil
   409  			_, err := cache.Read(ctx)
   410  			So(err, ShouldEqual, ErrUnknownReach)
   411  		})
   412  
   413  		Convey(`Write`, func() {
   414  			err := cache.Write(ctx, invs)
   415  			So(err, ShouldBeNil)
   416  
   417  			So(conn.received, ShouldResemble, [][]any{
   418  				{"SET", "reach4:inv", conn.received[0][2]},
   419  				{"EXPIRE", "reach4:inv", 2592000},
   420  			})
   421  			actual, err := unmarshalReachableInvocations(conn.received[0][2].([]byte))
   422  			So(err, ShouldBeNil)
   423  			So(actual, ShouldResemble, invs)
   424  		})
   425  	})
   426  }
   427  
   428  func ShouldResembleReachable(actual any, expected ...any) string {
   429  	a, ok := actual.(ReachableInvocations)
   430  	if !ok {
   431  		return "expected actual to be of type ReachableInvocations"
   432  	}
   433  	if len(expected) != 1 {
   434  		return "expected expected to be of length one"
   435  	}
   436  	e, ok := expected[0].(ReachableInvocations)
   437  	if !ok {
   438  		return "expected expected to be of type ReachableInvocations"
   439  	}
   440  
   441  	if msg := ShouldResemble(a.Invocations, e.Invocations); msg != "" {
   442  		return msg
   443  	}
   444  	if msg := ShouldEqual(len(a.Sources), len(e.Sources)); msg != "" {
   445  		return fmt.Sprintf("comparing sources: %s", msg)
   446  	}
   447  	for key := range e.Sources {
   448  		if msg := ShouldResembleProto(a.Sources[key], e.Sources[key]); msg != "" {
   449  			return fmt.Sprintf("comparing sources[%s]: %s", key, msg)
   450  		}
   451  	}
   452  	return ""
   453  }