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 }