go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/resultdb/internal/services/recorder/batch_create_artifacts_test.go (about)

     1  // Copyright 2021 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 recorder
    16  
    17  import (
    18  	"context"
    19  	"crypto/sha256"
    20  	"encoding/hex"
    21  	"fmt"
    22  	"testing"
    23  	"time"
    24  
    25  	repb "github.com/bazelbuild/remote-apis/build/bazel/remote/execution/v2"
    26  	spb "google.golang.org/genproto/googleapis/rpc/status"
    27  	"google.golang.org/grpc"
    28  	"google.golang.org/grpc/codes"
    29  	"google.golang.org/grpc/metadata"
    30  	"google.golang.org/protobuf/types/known/timestamppb"
    31  
    32  	"go.chromium.org/luci/common/errors"
    33  	"go.chromium.org/luci/common/tsmon"
    34  	"go.chromium.org/luci/server/auth"
    35  	"go.chromium.org/luci/server/auth/authtest"
    36  
    37  	"go.chromium.org/luci/resultdb/bqutil"
    38  	"go.chromium.org/luci/resultdb/internal/artifacts"
    39  	"go.chromium.org/luci/resultdb/internal/config"
    40  	"go.chromium.org/luci/resultdb/internal/invocations"
    41  	"go.chromium.org/luci/resultdb/internal/spanutil"
    42  	"go.chromium.org/luci/resultdb/internal/testutil"
    43  	"go.chromium.org/luci/resultdb/internal/testutil/insert"
    44  	bqpb "go.chromium.org/luci/resultdb/proto/bq"
    45  	configpb "go.chromium.org/luci/resultdb/proto/config"
    46  	pb "go.chromium.org/luci/resultdb/proto/v1"
    47  
    48  	. "github.com/smartystreets/goconvey/convey"
    49  	. "go.chromium.org/luci/common/testing/assertions"
    50  )
    51  
    52  // fakeRBEClient mocks BatchUpdateBlobs.
    53  type fakeRBEClient struct {
    54  	repb.ContentAddressableStorageClient
    55  	req  *repb.BatchUpdateBlobsRequest
    56  	resp *repb.BatchUpdateBlobsResponse
    57  	err  error
    58  }
    59  
    60  func (c *fakeRBEClient) BatchUpdateBlobs(ctx context.Context, in *repb.BatchUpdateBlobsRequest, opts ...grpc.CallOption) (*repb.BatchUpdateBlobsResponse, error) {
    61  	c.req = in
    62  	return c.resp, c.err
    63  }
    64  
    65  func (c *fakeRBEClient) mockResp(err error, cds ...codes.Code) {
    66  	c.err = err
    67  	c.resp = &repb.BatchUpdateBlobsResponse{}
    68  	for _, cd := range cds {
    69  		c.resp.Responses = append(c.resp.Responses, &repb.BatchUpdateBlobsResponse_Response{
    70  			Status: &spb.Status{Code: int32(cd)},
    71  		})
    72  	}
    73  }
    74  
    75  type fakeBQClient struct {
    76  	Rows  []*bqpb.TextArtifactRow
    77  	Error error
    78  }
    79  
    80  func (c *fakeBQClient) InsertArtifactRows(ctx context.Context, rows []*bqpb.TextArtifactRow) error {
    81  	if c.Error != nil {
    82  		return c.Error
    83  	}
    84  	c.Rows = append(c.Rows, rows...)
    85  	return nil
    86  }
    87  
    88  func TestNewArtifactCreationRequestsFromProto(t *testing.T) {
    89  	newArtReq := func(parent, artID, contentType string) *pb.CreateArtifactRequest {
    90  		return &pb.CreateArtifactRequest{
    91  			Parent:   parent,
    92  			Artifact: &pb.Artifact{ArtifactId: artID, ContentType: contentType},
    93  		}
    94  	}
    95  
    96  	Convey("newArtifactCreationRequestsFromProto", t, func() {
    97  		bReq := &pb.BatchCreateArtifactsRequest{}
    98  		invArt := newArtReq("invocations/inv1", "art1", "text/html")
    99  		trArt := newArtReq("invocations/inv1/tests/t1/results/r1", "art2", "image/png")
   100  
   101  		Convey("successes", func() {
   102  			bReq.Requests = append(bReq.Requests, invArt)
   103  			bReq.Requests = append(bReq.Requests, trArt)
   104  			invID, arts, err := parseBatchCreateArtifactsRequest(bReq)
   105  			So(err, ShouldBeNil)
   106  			So(invID, ShouldEqual, invocations.ID("inv1"))
   107  			So(len(arts), ShouldEqual, len(bReq.Requests))
   108  
   109  			// invocation-level artifact
   110  			So(arts[0].artifactID, ShouldEqual, "art1")
   111  			So(arts[0].parentID(), ShouldEqual, artifacts.ParentID("", ""))
   112  			So(arts[0].contentType, ShouldEqual, "text/html")
   113  
   114  			// test-result-level artifact
   115  			So(arts[1].artifactID, ShouldEqual, "art2")
   116  			So(arts[1].parentID(), ShouldEqual, artifacts.ParentID("t1", "r1"))
   117  			So(arts[1].contentType, ShouldEqual, "image/png")
   118  		})
   119  
   120  		Convey("mismatched size_bytes", func() {
   121  			bReq.Requests = append(bReq.Requests, trArt)
   122  			trArt.Artifact.SizeBytes = 123
   123  			trArt.Artifact.Contents = make([]byte, 10249)
   124  			_, _, err := parseBatchCreateArtifactsRequest(bReq)
   125  			So(err, ShouldErrLike, `sizeBytes and contents are specified but don't match`)
   126  		})
   127  
   128  		Convey("ignored size_bytes", func() {
   129  			bReq.Requests = append(bReq.Requests, trArt)
   130  			trArt.Artifact.SizeBytes = 0
   131  			trArt.Artifact.Contents = make([]byte, 10249)
   132  			_, arts, err := parseBatchCreateArtifactsRequest(bReq)
   133  			So(err, ShouldBeNil)
   134  			So(arts[0].size, ShouldEqual, 10249)
   135  		})
   136  
   137  		Convey("contents and gcs_uri both specified", func() {
   138  			bReq.Requests = append(bReq.Requests, trArt)
   139  			trArt.Artifact.SizeBytes = 0
   140  			trArt.Artifact.Contents = make([]byte, 10249)
   141  			trArt.Artifact.GcsUri = "gs://testbucket/testfile"
   142  			_, _, err := parseBatchCreateArtifactsRequest(bReq)
   143  			So(err, ShouldErrLike, `only one of contents and gcs_uri can be given`)
   144  		})
   145  
   146  		Convey("sum() of artifact.Contents is too big", func() {
   147  			for i := 0; i < 11; i++ {
   148  				req := newArtReq("invocations/inv1", fmt.Sprintf("art%d", i), "text/html")
   149  				req.Artifact.Contents = make([]byte, 1024*1024)
   150  				bReq.Requests = append(bReq.Requests, req)
   151  			}
   152  			_, _, err := parseBatchCreateArtifactsRequest(bReq)
   153  			So(err, ShouldErrLike, "the total size of artifact contents exceeded")
   154  		})
   155  
   156  		Convey("if more than one invocations", func() {
   157  			bReq.Requests = append(bReq.Requests, newArtReq("invocations/inv1", "art1", "text/html"))
   158  			bReq.Requests = append(bReq.Requests, newArtReq("invocations/inv2", "art1", "text/html"))
   159  			_, _, err := parseBatchCreateArtifactsRequest(bReq)
   160  			So(err, ShouldErrLike, `only one invocation is allowed: "inv1", "inv2"`)
   161  		})
   162  	})
   163  }
   164  
   165  func TestBatchCreateArtifacts(t *testing.T) {
   166  	// metric field values for Artifact table
   167  	artMFVs := []any{string(spanutil.Artifacts), string(spanutil.Inserted), insert.TestRealm}
   168  
   169  	Convey("TestBatchCreateArtifacts", t, func() {
   170  		ctx := testutil.SpannerTestContext(t)
   171  		ctx = testutil.TestProjectConfigContext(ctx, "testproject", "user:test@test.com", "testbucket")
   172  		token, err := generateInvocationToken(ctx, "inv")
   173  		So(err, ShouldBeNil)
   174  		ctx = metadata.NewIncomingContext(ctx, metadata.Pairs(pb.UpdateTokenMetadataKey, token))
   175  		ctx, _ = tsmon.WithDummyInMemory(ctx)
   176  		store := tsmon.Store(ctx)
   177  		ctx = auth.WithState(ctx, &authtest.FakeState{
   178  			Identity: "user:test@test.com",
   179  		})
   180  
   181  		err = config.SetServiceConfig(ctx, &configpb.Config{
   182  			BqArtifactExportConfig: &configpb.BqArtifactExportConfig{
   183  				Enabled:       true,
   184  				ExportPercent: 100,
   185  			},
   186  		})
   187  		So(err, ShouldBeNil)
   188  
   189  		casClient := &fakeRBEClient{}
   190  		recorder := newTestRecorderServer()
   191  		recorder.casClient = casClient
   192  		bReq := &pb.BatchCreateArtifactsRequest{}
   193  
   194  		appendArtReq := func(aID, content, cType, parent string) {
   195  			bReq.Requests = append(bReq.Requests, &pb.CreateArtifactRequest{
   196  				Parent: parent,
   197  				Artifact: &pb.Artifact{
   198  					ArtifactId: aID, Contents: []byte(content), ContentType: cType,
   199  				},
   200  			})
   201  		}
   202  		appendGcsArtReq := func(aID string, cSize int64, cType string, gcsURI string) {
   203  			bReq.Requests = append(bReq.Requests, &pb.CreateArtifactRequest{
   204  				Parent: "invocations/inv",
   205  				Artifact: &pb.Artifact{
   206  					ArtifactId: aID, SizeBytes: cSize, ContentType: cType, GcsUri: gcsURI,
   207  				},
   208  			})
   209  		}
   210  
   211  		fetchState := func(parentID, aID string) (size int64, hash string, contentType string, gcsURI string) {
   212  			testutil.MustReadRow(
   213  				ctx, "Artifacts", invocations.ID("inv").Key(parentID, aID),
   214  				map[string]any{
   215  					"Size":        &size,
   216  					"RBECASHash":  &hash,
   217  					"ContentType": &contentType,
   218  					"GcsURI":      &gcsURI,
   219  				},
   220  			)
   221  			return
   222  		}
   223  		compHash := func(content string) string {
   224  			h := sha256.Sum256([]byte(content))
   225  			return hex.EncodeToString(h[:])
   226  		}
   227  
   228  		Convey("GCS reference isAllowed", func() {
   229  			Convey("reference is allowed", func() {
   230  				testutil.SetGCSAllowedBuckets(ctx, "testproject", "user:test@test.com", "testbucket")
   231  
   232  				testutil.MustApply(ctx, insert.Invocation("inv", pb.Invocation_ACTIVE, nil))
   233  				appendGcsArtReq("art1", 0, "text/plain", "gs://testbucket/art1")
   234  
   235  				_, err := recorder.BatchCreateArtifacts(ctx, bReq)
   236  				So(err, ShouldBeNil)
   237  			})
   238  			Convey("project not configured", func() {
   239  				testutil.SetGCSAllowedBuckets(ctx, "otherproject", "user:test@test.com", "testbucket")
   240  
   241  				testutil.MustApply(ctx, insert.Invocation("inv", pb.Invocation_ACTIVE, nil))
   242  				appendGcsArtReq("art1", 0, "text/plain", "gs://testbucket/art1")
   243  
   244  				_, err := recorder.BatchCreateArtifacts(ctx, bReq)
   245  				So(err, ShouldNotBeNil)
   246  				So(err.Error(), ShouldContainSubstring, "testproject")
   247  			})
   248  			Convey("user not configured", func() {
   249  				testutil.SetGCSAllowedBuckets(ctx, "testproject", "user:test@test.com", "testbucket")
   250  				ctx = auth.WithState(ctx, &authtest.FakeState{
   251  					Identity: "user:other@test.com",
   252  				})
   253  				testutil.MustApply(ctx, insert.Invocation("inv", pb.Invocation_ACTIVE, nil))
   254  				appendGcsArtReq("art1", 0, "text/plain", "gs://testbucket/art1")
   255  
   256  				_, err := recorder.BatchCreateArtifacts(ctx, bReq)
   257  				So(err, ShouldNotBeNil)
   258  				So(err.Error(), ShouldContainSubstring, "user:other@test.com")
   259  			})
   260  			Convey("bucket not listed", func() {
   261  				testutil.SetGCSAllowedBuckets(ctx, "testproject", "user:test@test.com", "otherbucket")
   262  
   263  				testutil.MustApply(ctx, insert.Invocation("inv", pb.Invocation_ACTIVE, nil))
   264  				appendGcsArtReq("art1", 0, "text/plain", "gs://testbucket/art1")
   265  
   266  				_, err := recorder.BatchCreateArtifacts(ctx, bReq)
   267  				So(err, ShouldNotBeNil)
   268  				So(err.Error(), ShouldContainSubstring, "testbucket")
   269  			})
   270  		})
   271  		Convey("works", func() {
   272  			testutil.MustApply(ctx, insert.Invocation("inv", pb.Invocation_ACTIVE, map[string]any{
   273  				"CreateTime": time.Unix(10000, 0),
   274  			}))
   275  			appendArtReq("art1", "c0ntent", "text/plain", "invocations/inv")
   276  			appendArtReq("art2", "c1ntent", "text/richtext", "invocations/inv/tests/test_id/results/result_id")
   277  			appendGcsArtReq("art3", 0, "text/plain", "gs://testbucket/art3")
   278  			appendGcsArtReq("art4", 500, "text/richtext", "gs://testbucket/art4")
   279  
   280  			casClient.mockResp(nil, codes.OK, codes.OK)
   281  			bqClient := &fakeBQClient{}
   282  			recorder.bqExportClient = bqClient
   283  
   284  			resp, err := recorder.BatchCreateArtifacts(ctx, bReq)
   285  			So(err, ShouldBeNil)
   286  			So(resp, ShouldResemble, &pb.BatchCreateArtifactsResponse{
   287  				Artifacts: []*pb.Artifact{
   288  					{
   289  						Name:        "invocations/inv/artifacts/art1",
   290  						ArtifactId:  "art1",
   291  						ContentType: "text/plain",
   292  						SizeBytes:   7,
   293  					},
   294  					{
   295  						Name:        "invocations/inv/tests/test_id/results/result_id/artifacts/art2",
   296  						ArtifactId:  "art2",
   297  						ContentType: "text/richtext",
   298  						SizeBytes:   7,
   299  					},
   300  					{
   301  						Name:        "invocations/inv/artifacts/art3",
   302  						ArtifactId:  "art3",
   303  						ContentType: "text/plain",
   304  						SizeBytes:   0,
   305  					},
   306  					{
   307  						Name:        "invocations/inv/artifacts/art4",
   308  						ArtifactId:  "art4",
   309  						ContentType: "text/richtext",
   310  						SizeBytes:   500,
   311  					},
   312  				},
   313  			})
   314  			// verify the RBECAS reqs
   315  			So(casClient.req, ShouldResemble, &repb.BatchUpdateBlobsRequest{
   316  				InstanceName: "",
   317  				Requests: []*repb.BatchUpdateBlobsRequest_Request{
   318  					{
   319  						Digest: &repb.Digest{
   320  							Hash:      compHash("c0ntent"),
   321  							SizeBytes: int64(len("c0ntent")),
   322  						},
   323  						Data: []byte("c0ntent"),
   324  					},
   325  					{
   326  						Digest: &repb.Digest{
   327  							Hash:      compHash("c1ntent"),
   328  							SizeBytes: int64(len("c1ntent")),
   329  						},
   330  						Data: []byte("c1ntent"),
   331  					},
   332  				},
   333  			})
   334  			// verify the Spanner states
   335  			size, hash, cType, gcsURI := fetchState("", "art1")
   336  			So(size, ShouldEqual, int64(len("c0ntent")))
   337  			So(hash, ShouldEqual, artifacts.AddHashPrefix(compHash("c0ntent")))
   338  			So(cType, ShouldEqual, "text/plain")
   339  			So(gcsURI, ShouldEqual, "")
   340  
   341  			size, hash, cType, gcsURI = fetchState("tr/test_id/result_id", "art2")
   342  			So(size, ShouldEqual, int64(len("c1ntent")))
   343  			So(hash, ShouldEqual, artifacts.AddHashPrefix(compHash("c1ntent")))
   344  			So(cType, ShouldEqual, "text/richtext")
   345  			So(gcsURI, ShouldEqual, "")
   346  
   347  			size, hash, cType, gcsURI = fetchState("", "art3")
   348  			So(size, ShouldEqual, 0)
   349  			So(hash, ShouldEqual, "")
   350  			So(cType, ShouldEqual, "text/plain")
   351  			So(gcsURI, ShouldEqual, "gs://testbucket/art3")
   352  
   353  			size, hash, cType, gcsURI = fetchState("", "art4")
   354  			So(size, ShouldEqual, 500)
   355  			So(hash, ShouldEqual, "")
   356  			So(cType, ShouldEqual, "text/richtext")
   357  			So(gcsURI, ShouldEqual, "gs://testbucket/art4")
   358  
   359  			// RowCount metric should be increased by 4.
   360  			So(store.Get(ctx, spanutil.RowCounter, time.Time{}, artMFVs), ShouldEqual, 4)
   361  
   362  			// Verify the bigquery rows.
   363  			So(len(bqClient.Rows), ShouldEqual, 2)
   364  			So(bqClient.Rows, ShouldResembleProto, []*bqpb.TextArtifactRow{
   365  				{
   366  					Project:             "testproject",
   367  					Realm:               "testrealm",
   368  					InvocationId:        "inv",
   369  					ArtifactId:          "art1",
   370  					ContentType:         "text/plain",
   371  					Content:             "c0ntent",
   372  					NumShards:           1,
   373  					ShardId:             0,
   374  					ShardContentSize:    7,
   375  					ArtifactContentSize: 7,
   376  					PartitionTime:       timestamppb.New(time.Unix(10000, 0)),
   377  					ArtifactShard:       "art1:0",
   378  				},
   379  				{
   380  					Project:             "testproject",
   381  					Realm:               "testrealm",
   382  					InvocationId:        "inv",
   383  					ArtifactId:          "art2",
   384  					ContentType:         "text/richtext",
   385  					Content:             "c1ntent",
   386  					NumShards:           1,
   387  					ShardId:             0,
   388  					ShardContentSize:    7,
   389  					ArtifactContentSize: 7,
   390  					PartitionTime:       timestamppb.New(time.Unix(10000, 0)),
   391  					TestId:              "test_id",
   392  					ResultId:            "result_id",
   393  					ArtifactShard:       "art2:0",
   394  				},
   395  			})
   396  			So(artifactExportCounter.Get(ctx, "testproject", "success"), ShouldEqual, 2)
   397  		})
   398  
   399  		Convey("BatchUpdateBlobs fails", func() {
   400  			testutil.MustApply(ctx, insert.Invocation("inv", pb.Invocation_ACTIVE, nil))
   401  			appendArtReq("art1", "c0ntent", "text/plain", "invocations/inv")
   402  			appendArtReq("art2", "c1ntent", "text/richtext", "invocations/inv")
   403  
   404  			Convey("Partly", func() {
   405  				casClient.mockResp(nil, codes.OK, codes.InvalidArgument)
   406  				_, err := recorder.BatchCreateArtifacts(ctx, bReq)
   407  				So(err, ShouldErrLike, `artifact "invocations/inv/artifacts/art2": cas.BatchUpdateBlobs failed`)
   408  			})
   409  
   410  			Convey("Entirely", func() {
   411  				// exceeded the maximum size limit is the only possible error that
   412  				// can cause the entire request failed.
   413  				casClient.mockResp(errors.New("err"), codes.OK, codes.OK)
   414  				_, err := recorder.BatchCreateArtifacts(ctx, bReq)
   415  				So(err, ShouldErrLike, "cas.BatchUpdateBlobs failed")
   416  			})
   417  
   418  			// RowCount metric should have no changes from any of the above Convey()s.
   419  			So(store.Get(ctx, spanutil.RowCounter, time.Time{}, artMFVs), ShouldBeNil)
   420  		})
   421  
   422  		Convey("Token", func() {
   423  			appendArtReq("art1", "", "text/plain", "invocations/inv")
   424  			testutil.MustApply(ctx, insert.Invocation("inv", pb.Invocation_ACTIVE, nil))
   425  
   426  			Convey("Missing", func() {
   427  				ctx = metadata.NewIncomingContext(ctx, metadata.Pairs())
   428  				_, err = recorder.BatchCreateArtifacts(ctx, bReq)
   429  				So(err, ShouldHaveAppStatus, codes.Unauthenticated, `missing update-token`)
   430  			})
   431  			Convey("Wrong", func() {
   432  				ctx = metadata.NewIncomingContext(ctx, metadata.Pairs(pb.UpdateTokenMetadataKey, "rong"))
   433  				_, err = recorder.BatchCreateArtifacts(ctx, bReq)
   434  				So(err, ShouldHaveAppStatus, codes.PermissionDenied, `invalid update token`)
   435  			})
   436  		})
   437  
   438  		Convey("Verify state", func() {
   439  			casClient.mockResp(nil, codes.OK, codes.OK)
   440  
   441  			Convey("Finalized invocation", func() {
   442  				testutil.MustApply(ctx, insert.Invocation("inv", pb.Invocation_FINALIZED, nil))
   443  				appendArtReq("art1", "c0ntent", "text/plain", "invocations/inv")
   444  				_, err = recorder.BatchCreateArtifacts(ctx, bReq)
   445  				So(err, ShouldHaveAppStatus, codes.FailedPrecondition, `invocations/inv is not active`)
   446  			})
   447  
   448  			art := map[string]any{
   449  				"InvocationId": invocations.ID("inv"),
   450  				"ParentId":     "",
   451  				"ArtifactId":   "art1",
   452  				"RBECASHash":   artifacts.AddHashPrefix(compHash("c0ntent")),
   453  				"Size":         len("c0ntent"),
   454  				"ContentType":  "text/plain",
   455  			}
   456  
   457  			gcsArt := map[string]any{
   458  				"InvocationId": invocations.ID("inv"),
   459  				"ParentId":     "",
   460  				"ArtifactId":   "art1",
   461  				"ContentType":  "text/plain",
   462  				"GcsURI":       "gs://testbucket/art1",
   463  			}
   464  
   465  			Convey("Same artifact exists", func() {
   466  				testutil.MustApply(ctx,
   467  					insert.Invocation("inv", pb.Invocation_ACTIVE, nil),
   468  					spanutil.InsertMap("Artifacts", art),
   469  				)
   470  				appendArtReq("art1", "c0ntent", "text/plain", "invocations/inv")
   471  				resp, err := recorder.BatchCreateArtifacts(ctx, bReq)
   472  				So(err, ShouldBeNil)
   473  				So(resp, ShouldResemble, &pb.BatchCreateArtifactsResponse{})
   474  			})
   475  
   476  			Convey("Different artifact exists", func() {
   477  				testutil.SetGCSAllowedBuckets(ctx, "testproject", "user:test@test.com", "testbucket")
   478  				testutil.MustApply(ctx,
   479  					insert.Invocation("inv", pb.Invocation_ACTIVE, nil),
   480  					spanutil.InsertMap("Artifacts", art),
   481  				)
   482  
   483  				appendArtReq("art1", "c0ntent", "text/plain", "invocations/inv")
   484  				bReq.Requests[0].Artifact.Contents = []byte("loooong content")
   485  				_, err := recorder.BatchCreateArtifacts(ctx, bReq)
   486  				So(err, ShouldHaveAppStatus, codes.AlreadyExists, "exists w/ different size")
   487  
   488  				bReq.Requests[0].Artifact.Contents = []byte("c1ntent")
   489  				_, err = recorder.BatchCreateArtifacts(ctx, bReq)
   490  				So(err, ShouldHaveAppStatus, codes.AlreadyExists, "exists w/ different hash")
   491  
   492  				bReq.Requests[0].Artifact.Contents = []byte("")
   493  				bReq.Requests[0].Artifact.GcsUri = "gs://testbucket/art1"
   494  				_, err = recorder.BatchCreateArtifacts(ctx, bReq)
   495  				So(err, ShouldHaveAppStatus, codes.AlreadyExists, "exists w/ different storage scheme")
   496  			})
   497  
   498  			Convey("Different artifact exists GCS", func() {
   499  				testutil.SetGCSAllowedBuckets(ctx, "testproject", "user:test@test.com", "testbucket")
   500  				testutil.MustApply(ctx,
   501  					insert.Invocation("inv", pb.Invocation_ACTIVE, nil),
   502  					spanutil.InsertMap("Artifacts", gcsArt),
   503  				)
   504  
   505  				appendArtReq("art1", "c0ntent", "text/plain", "invocations/inv")
   506  				_, err := recorder.BatchCreateArtifacts(ctx, bReq)
   507  				So(err, ShouldHaveAppStatus, codes.AlreadyExists, "exists w/ different storage scheme")
   508  
   509  				bReq.Requests[0].Artifact.Contents = []byte("")
   510  				bReq.Requests[0].Artifact.GcsUri = "gs://testbucket/art2"
   511  				_, err = recorder.BatchCreateArtifacts(ctx, bReq)
   512  				So(err, ShouldHaveAppStatus, codes.AlreadyExists, "exists w/ different GCS URI")
   513  
   514  				appendArtReq("art1", "c0ntent", "text/plain", "invocations/inv")
   515  				bReq.Requests[0].Artifact.SizeBytes = 42
   516  				_, err = recorder.BatchCreateArtifacts(ctx, bReq)
   517  				So(err, ShouldHaveAppStatus, codes.AlreadyExists, "exists w/ different size")
   518  			})
   519  
   520  			// RowCount metric should have no changes from any of the above Convey()s.
   521  			So(store.Get(ctx, spanutil.RowCounter, time.Time{}, artMFVs), ShouldBeNil)
   522  		})
   523  
   524  		Convey("Too many requests", func() {
   525  			bReq.Requests = make([]*pb.CreateArtifactRequest, 1000)
   526  			_, err := recorder.BatchCreateArtifacts(ctx, bReq)
   527  			So(err, ShouldHaveAppStatus, codes.InvalidArgument, "the number of requests in the batch exceeds 500")
   528  		})
   529  	})
   530  }
   531  
   532  func TestFilterAndThrottle(t *testing.T) {
   533  	ctx := context.Background()
   534  	// Setup tsmon
   535  	ctx, _ = tsmon.WithDummyInMemory(ctx)
   536  
   537  	invInfo := &invocationInfo{realm: "chromium:ci"}
   538  	Convey("Filter artifact", t, func() {
   539  		artReqs := []*artifactCreationRequest{
   540  			{
   541  				testID:      "test1",
   542  				contentType: "",
   543  			},
   544  			{
   545  				testID:      "test2",
   546  				contentType: "text/plain",
   547  			},
   548  			{
   549  				testID:      "test3",
   550  				contentType: "image/png",
   551  			},
   552  			{
   553  				testID:      "test4",
   554  				contentType: "text/html",
   555  			},
   556  		}
   557  		results := filterTextArtifactRequests(ctx, artReqs, invInfo)
   558  		So(results, ShouldResemble, []*artifactCreationRequest{
   559  			{
   560  				testID:      "test2",
   561  				contentType: "text/plain",
   562  			},
   563  			{
   564  				testID:      "test4",
   565  				contentType: "text/html",
   566  			},
   567  		})
   568  		So(artifactContentCounter.Get(ctx, "chromium", "text"), ShouldEqual, 2)
   569  		So(artifactContentCounter.Get(ctx, "chromium", "nontext"), ShouldEqual, 1)
   570  		So(artifactContentCounter.Get(ctx, "chromium", "empty"), ShouldEqual, 1)
   571  	})
   572  
   573  	Convey("Throttle artifact", t, func() {
   574  		artReqs := []*artifactCreationRequest{
   575  			{
   576  				testID:     "test",
   577  				artifactID: "artifact38", // Hash value 0
   578  			},
   579  			{
   580  				testID:     "test",
   581  				artifactID: "artifact158", // Hash value 99
   582  			},
   583  			{
   584  				testID:     "test",
   585  				artifactID: "artifact230", // Hash value 32
   586  			},
   587  			{
   588  				testID:     "test",
   589  				artifactID: "artifact232", // Hash value 54
   590  			},
   591  			{
   592  				testID:     "test",
   593  				artifactID: "artifact341", // Hash value 91
   594  			},
   595  		}
   596  		// 0%.
   597  		results, err := throttleArtifactsForBQ(artReqs, 0)
   598  		So(err, ShouldBeNil)
   599  		So(results, ShouldResemble, []*artifactCreationRequest{})
   600  
   601  		// 1%.
   602  		results, err = throttleArtifactsForBQ(artReqs, 1)
   603  		So(err, ShouldBeNil)
   604  		So(results, ShouldResemble, []*artifactCreationRequest{
   605  			{
   606  				testID:     "test",
   607  				artifactID: "artifact38", // Hash value 0
   608  			},
   609  		})
   610  
   611  		// 33%.
   612  		results, err = throttleArtifactsForBQ(artReqs, 33)
   613  		So(err, ShouldBeNil)
   614  		So(results, ShouldResemble, []*artifactCreationRequest{
   615  			{
   616  				testID:     "test",
   617  				artifactID: "artifact38", // Hash value 0
   618  			},
   619  			{
   620  				testID:     "test",
   621  				artifactID: "artifact230", // Hash value 32
   622  			},
   623  		})
   624  
   625  		// 90%.
   626  		results, err = throttleArtifactsForBQ(artReqs, 90)
   627  		So(err, ShouldBeNil)
   628  		So(results, ShouldResemble, []*artifactCreationRequest{
   629  			{
   630  				testID:     "test",
   631  				artifactID: "artifact38", // Hash value 0
   632  			},
   633  			{
   634  				testID:     "test",
   635  				artifactID: "artifact230", // Hash value 32
   636  			},
   637  			{
   638  				testID:     "test",
   639  				artifactID: "artifact232", // Hash value 54
   640  			},
   641  		})
   642  
   643  		// 100%.
   644  		results, err = throttleArtifactsForBQ(artReqs, 100)
   645  		So(err, ShouldBeNil)
   646  		So(results, ShouldResemble, []*artifactCreationRequest{
   647  			{
   648  				testID:     "test",
   649  				artifactID: "artifact38", // Hash value 0
   650  			},
   651  			{
   652  				testID:     "test",
   653  				artifactID: "artifact158", // Hash value 99
   654  			},
   655  			{
   656  				testID:     "test",
   657  				artifactID: "artifact230", // Hash value 32
   658  			},
   659  			{
   660  				testID:     "test",
   661  				artifactID: "artifact232", // Hash value 54
   662  			},
   663  			{
   664  				testID:     "test",
   665  				artifactID: "artifact341", // Hash value 91
   666  			},
   667  		})
   668  	})
   669  }
   670  
   671  func TestReqToProto(t *testing.T) {
   672  	Convey("Artifact request to proto", t, func() {
   673  		ctx := context.Background()
   674  		req := &artifactCreationRequest{
   675  			testID:      "testid",
   676  			resultID:    "resultid",
   677  			artifactID:  "artifactid",
   678  			contentType: "text/plain",
   679  			size:        20,
   680  			data:        []byte("0123456789abcdefghij"),
   681  		}
   682  		invInfo := &invocationInfo{
   683  			id:         "invid",
   684  			realm:      "chromium:ci",
   685  			createTime: time.Unix(1000, 0),
   686  		}
   687  		results, err := reqToProtos(ctx, req, invInfo, 10, 10)
   688  		So(err, ShouldBeNil)
   689  		So(results, ShouldResembleProto, []*bqpb.TextArtifactRow{
   690  			{
   691  				Project:             "chromium",
   692  				Realm:               "ci",
   693  				InvocationId:        "invid",
   694  				TestId:              "testid",
   695  				ResultId:            "resultid",
   696  				ArtifactId:          "artifactid",
   697  				ContentType:         "text/plain",
   698  				NumShards:           2,
   699  				ShardId:             0,
   700  				Content:             "0123456789",
   701  				ShardContentSize:    10,
   702  				ArtifactContentSize: 20,
   703  				PartitionTime:       timestamppb.New(time.Unix(1000, 0)),
   704  				ArtifactShard:       "artifactid:0",
   705  			},
   706  			{
   707  				Project:             "chromium",
   708  				Realm:               "ci",
   709  				InvocationId:        "invid",
   710  				TestId:              "testid",
   711  				ResultId:            "resultid",
   712  				ArtifactId:          "artifactid",
   713  				ContentType:         "text/plain",
   714  				NumShards:           2,
   715  				ShardId:             1,
   716  				Content:             "abcdefghij",
   717  				ShardContentSize:    10,
   718  				ArtifactContentSize: 20,
   719  				PartitionTime:       timestamppb.New(time.Unix(1000, 0)),
   720  				ArtifactShard:       "artifactid:1",
   721  			},
   722  		})
   723  	})
   724  }
   725  
   726  func TestArtifactExportCounter(t *testing.T) {
   727  	Convey("BQ export error", t, func() {
   728  		ctx := context.Background()
   729  		ctx, _ = tsmon.WithDummyInMemory(ctx)
   730  		client := &fakeBQClient{
   731  			Error: fmt.Errorf("Some error"),
   732  		}
   733  		invInfo := &invocationInfo{
   734  			id:         "invid",
   735  			realm:      "chromium:ci",
   736  			createTime: time.Unix(1000, 0),
   737  		}
   738  		reqs := []*artifactCreationRequest{
   739  			{
   740  				testID:      "testid",
   741  				resultID:    "resultid",
   742  				artifactID:  "artifactid",
   743  				contentType: "text/plain",
   744  				size:        20,
   745  				data:        []byte("0123456789abcdefghij"),
   746  			},
   747  		}
   748  		err := uploadArtifactsToBQ(ctx, client, reqs, invInfo)
   749  		So(err, ShouldNotBeNil)
   750  		So(artifactExportCounter.Get(ctx, "chromium", "failure_bq"), ShouldEqual, 1)
   751  	})
   752  
   753  	Convey("Input error", t, func() {
   754  		ctx := context.Background()
   755  		ctx, _ = tsmon.WithDummyInMemory(ctx)
   756  		client := &fakeBQClient{
   757  			Error: errors.Annotate(fmt.Errorf("error"), "marshal proto").Tag(errors.BoolTag{Key: bqutil.InvalidRowTagKey}).Err(),
   758  		}
   759  		invInfo := &invocationInfo{
   760  			id:         "invid",
   761  			realm:      "chromium:ci",
   762  			createTime: time.Unix(1000, 0),
   763  		}
   764  		reqs := []*artifactCreationRequest{
   765  			{
   766  				testID:      "testid",
   767  				resultID:    "resultid",
   768  				artifactID:  "artifactid",
   769  				contentType: "text/plain",
   770  				size:        20,
   771  				data:        []byte("0123456789abcdefghij"),
   772  			},
   773  		}
   774  		err := uploadArtifactsToBQ(ctx, client, reqs, invInfo)
   775  		So(err, ShouldNotBeNil)
   776  		So(artifactExportCounter.Get(ctx, "chromium", "failure_input"), ShouldEqual, 1)
   777  	})
   778  }