diff --git a/entity/BUILD.bazel b/entity/BUILD.bazel index deff8984..c64bc795 100644 --- a/entity/BUILD.bazel +++ b/entity/BUILD.bazel @@ -5,6 +5,7 @@ go_library( srcs = [ "build_description.go", "computation_strategy.go", + "optimized_target.go", "target_graph.go", ], importpath = "github.com/uber/tango/entity", diff --git a/entity/optimized_target.go b/entity/optimized_target.go new file mode 100644 index 00000000..c97e1de1 --- /dev/null +++ b/entity/optimized_target.go @@ -0,0 +1,53 @@ +package entity + +// OptimizedTarget is the domain representation of a single build target +// with its hash, dependencies, and metadata. All references use string +// names — ID assignment is a serialization concern handled by the mapper. +type OptimizedTarget struct { + Name string + Hash string + Dependencies []string + RuleType string + Tags []string + Root bool + External bool + Attributes map[string]string +} + +// TargetGraph is the domain representation of a complete target graph: +// a topologically sorted list of optimized targets. +type TargetGraph struct { + Targets []OptimizedTarget +} + +// IDTarget is the compact, ID-mapped representation of a target used for +// streaming and storage. String fields are replaced with int32 IDs that +// reference the accompanying GraphMetadata maps. +type IDTarget struct { + ID int32 + Hash string + DirectDependencies []int32 + RuleType int32 + Tags []int32 + Root bool + External bool + Attributes map[int32]int32 +} + +// GraphMetadata holds the ID-to-string mappings that accompany a set of +// IDTarget entries. Consumers merge metadata across chunks before +// resolving IDs. +type GraphMetadata struct { + TargetIDMapping map[int32]string + RuleTypeMapping map[int32]string + TagMapping map[int32]string + AttributeNameMapping map[int32]string + AttributeStringValueMapping map[int32]string +} + +// GraphChunk is one piece of a streamed target graph — either a batch of +// ID-mapped targets or a metadata mapping. Exactly one field is non-nil. +type GraphChunk struct { + Targets []IDTarget + Metadata *GraphMetadata +} diff --git a/internal/mapper/BUILD.bazel b/internal/mapper/BUILD.bazel index 77903eb8..4a3ec64a 100644 --- a/internal/mapper/BUILD.bazel +++ b/internal/mapper/BUILD.bazel @@ -9,8 +9,12 @@ go_library( importpath = "github.com/uber/tango/internal/mapper", visibility = ["//visibility:public"], deps = [ + "//core/targethasher", "//entity", + "//internal/mapper/idmapper", + "//internal/streaming", "//tangopb", + "@com_github_bazelbuild_buildtools//build_proto", ], ) @@ -22,6 +26,7 @@ go_test( ], embed = [":mapper"], deps = [ + "//core/targethasher", "//entity", "//tangopb", "@com_github_stretchr_testify//assert", diff --git a/internal/mapper/target_graph.go b/internal/mapper/target_graph.go index 45717d82..8ca4d459 100644 --- a/internal/mapper/target_graph.go +++ b/internal/mapper/target_graph.go @@ -1,12 +1,20 @@ package mapper import ( + "context" + "encoding/hex" "errors" + buildpb "github.com/bazelbuild/buildtools/build_proto" + "github.com/uber/tango/core/targethasher" "github.com/uber/tango/entity" + "github.com/uber/tango/internal/mapper/idmapper" + "github.com/uber/tango/internal/streaming" "github.com/uber/tango/tangopb" ) +const cancelCheckInterval = 4096 + // ProtoToGetTargetGraphRequest converts a proto GetTargetGraphRequest to the // domain type. Returns an error if req is nil or its BuildDescription fails // validation (see ProtoToBuildDescription). @@ -24,3 +32,247 @@ func ProtoToGetTargetGraphRequest(req *tangopb.GetTargetGraphRequest) (entity.Ge BypassCache: req.GetBypassCache(), }, nil } + +// ResultToTargetGraph converts a targethasher.Result into a proto-free +// entity.TargetGraph. Targets are emitted in the topological order given +// by result.TargetNames. Only STRING attributes with non-nil name and +// value are included. +func ResultToTargetGraph(ctx context.Context, result targethasher.Result) (entity.TargetGraph, error) { + targets := make([]entity.OptimizedTarget, 0, len(result.TargetNames)) + for i, name := range result.TargetNames { + if i%cancelCheckInterval == 0 { + if err := ctx.Err(); err != nil { + return entity.TargetGraph{}, err + } + } + t, ok := result.Targets[name] + if !ok { + continue + } + ot := entity.OptimizedTarget{ + Name: name, + Hash: hex.EncodeToString(t.Hash), + Dependencies: t.Deps, + RuleType: t.RuleType, + Tags: t.Tags, + Root: t.Root, + External: t.External, + } + if len(t.Attributes) > 0 { + attrs := make(map[string]string, len(t.Attributes)) + for _, attr := range t.Attributes { + if attr.GetType() == buildpb.Attribute_STRING && attr.Name != nil && attr.StringValue != nil { + attrs[*attr.Name] = *attr.StringValue + } + } + if len(attrs) > 0 { + ot.Attributes = attrs + } + } + targets = append(targets, ot) + } + return entity.TargetGraph{Targets: targets}, nil +} + +// TargetGraphToChunks converts a string-based entity.TargetGraph into +// ID-mapped entity.GraphChunk slices, ready for storage or proto +// conversion. Each chunk is bounded by maxMessageBytes of estimated wire +// size. +func TargetGraphToChunks(ctx context.Context, graph entity.TargetGraph, maxMessageBytes int) ([]entity.GraphChunk, error) { + targetNamesMapping := make(map[string]int32, len(graph.Targets)) + for i, t := range graph.Targets { + targetNamesMapping[t.Name] = int32(i + 1) + } + + ruleTypeMapper := idmapper.NewMapper() + tagMapper := idmapper.NewMapper() + attrNameMapper := idmapper.NewMapper() + attrStrValMapper := idmapper.NewMapper() + + protoTargets := make([]*tangopb.OptimizedTarget, 0, len(graph.Targets)) + idTargets := make([]entity.IDTarget, 0, len(graph.Targets)) + for i, t := range graph.Targets { + if i%cancelCheckInterval == 0 { + if err := ctx.Err(); err != nil { + return nil, err + } + } + depIDs := make([]int32, 0, len(t.Dependencies)) + for _, depName := range t.Dependencies { + depID, ok := targetNamesMapping[depName] + if !ok { + continue + } + depIDs = append(depIDs, depID) + } + + id := targetNamesMapping[t.Name] + idt := entity.IDTarget{ + ID: id, + Hash: t.Hash, + DirectDependencies: depIDs, + Root: t.Root, + External: t.External, + } + + if t.RuleType != "" { + idt.RuleType = ruleTypeMapper.ID(t.RuleType) + } + if len(t.Tags) > 0 { + tagIDs := make([]int32, 0, len(t.Tags)) + for _, tag := range t.Tags { + tagIDs = append(tagIDs, tagMapper.ID(tag)) + } + idt.Tags = tagIDs + } + if len(t.Attributes) > 0 { + attrs := make(map[int32]int32, len(t.Attributes)) + for k, v := range t.Attributes { + attrs[attrNameMapper.ID(k)] = attrStrValMapper.ID(v) + } + idt.Attributes = attrs + } + + idTargets = append(idTargets, idt) + protoTargets = append(protoTargets, idTargetToProtoTarget(&idt)) + } + + targetIDToName := make(map[int32]string, len(targetNamesMapping)) + for s, id := range targetNamesMapping { + targetIDToName[id] = s + } + + // Use proto Size() for accurate splitting, then build entity chunks. + targetGroups, err := streaming.SplitBySize(protoTargets, maxMessageBytes) + if err != nil { + return nil, err + } + + var chunks []entity.GraphChunk + idx := 0 + for _, g := range targetGroups { + chunk := entity.GraphChunk{ + Targets: idTargets[idx : idx+len(g)], + } + idx += len(g) + chunks = append(chunks, chunk) + } + + metaGroups, err := streaming.SplitMetadata( + targetIDToName, + ruleTypeMapper.Invert(), + tagMapper.Invert(), + attrNameMapper.Invert(), + attrStrValMapper.Invert(), + maxMessageBytes, + ) + if err != nil { + return nil, err + } + for _, meta := range metaGroups { + chunks = append(chunks, entity.GraphChunk{ + Metadata: meta, + }) + } + + return chunks, nil +} + +// TargetGraphToProto converts an entity.TargetGraph into chunked +// GetTargetGraphResponse messages ready for streaming. Each message is +// bounded by maxMessageBytes of real wire size. +func TargetGraphToProto(ctx context.Context, graph entity.TargetGraph, maxMessageBytes int) ([]*tangopb.GetTargetGraphResponse, error) { + chunks, err := TargetGraphToChunks(ctx, graph, maxMessageBytes) + if err != nil { + return nil, err + } + responses := make([]*tangopb.GetTargetGraphResponse, 0, len(chunks)) + for i := range chunks { + responses = append(responses, GraphChunkToProto(&chunks[i])) + } + return responses, nil +} + +// GraphChunkToProto converts an entity.GraphChunk to the corresponding +// proto GetTargetGraphResponse. +func GraphChunkToProto(chunk *entity.GraphChunk) *tangopb.GetTargetGraphResponse { + if chunk.Metadata != nil { + return &tangopb.GetTargetGraphResponse{ + Item: &tangopb.GetTargetGraphResponse_Metadata{ + Metadata: entityMetadataToProto(chunk.Metadata), + }, + } + } + targets := make([]*tangopb.OptimizedTarget, len(chunk.Targets)) + for i := range chunk.Targets { + targets[i] = idTargetToProtoTarget(&chunk.Targets[i]) + } + return &tangopb.GetTargetGraphResponse{ + Item: &tangopb.GetTargetGraphResponse_Targets{ + Targets: &tangopb.OptimizedTargets{Targets: targets}, + }, + } +} + +// ProtoToGraphChunk converts a proto GetTargetGraphResponse to an +// entity.GraphChunk. +func ProtoToGraphChunk(resp *tangopb.GetTargetGraphResponse) entity.GraphChunk { + switch item := resp.GetItem().(type) { + case *tangopb.GetTargetGraphResponse_Targets: + targets := make([]entity.IDTarget, len(item.Targets.GetTargets())) + for i, t := range item.Targets.GetTargets() { + targets[i] = protoTargetToIDTarget(t) + } + return entity.GraphChunk{Targets: targets} + case *tangopb.GetTargetGraphResponse_Metadata: + return entity.GraphChunk{Metadata: protoMetadataToEntity(item.Metadata)} + default: + return entity.GraphChunk{} + } +} + +func idTargetToProtoTarget(t *entity.IDTarget) *tangopb.OptimizedTarget { + return &tangopb.OptimizedTarget{ + Id: t.ID, + Hash: t.Hash, + DirectDependencies: t.DirectDependencies, + RuleType: t.RuleType, + Tags: t.Tags, + Root: t.Root, + External: t.External, + Attributes: t.Attributes, + } +} + +func protoTargetToIDTarget(t *tangopb.OptimizedTarget) entity.IDTarget { + return entity.IDTarget{ + ID: t.GetId(), + Hash: t.GetHash(), + DirectDependencies: t.GetDirectDependencies(), + RuleType: t.GetRuleType(), + Tags: t.GetTags(), + Root: t.GetRoot(), + External: t.GetExternal(), + Attributes: t.GetAttributes(), + } +} + +func entityMetadataToProto(m *entity.GraphMetadata) *tangopb.Metadata { + return &tangopb.Metadata{ + TargetIdMapping: m.TargetIDMapping, + RuleTypeMapping: m.RuleTypeMapping, + TagMapping: m.TagMapping, + AttributeNameMapping: m.AttributeNameMapping, + AttributeStringValueMapping: m.AttributeStringValueMapping, + } +} + +func protoMetadataToEntity(m *tangopb.Metadata) *entity.GraphMetadata { + return &entity.GraphMetadata{ + TargetIDMapping: m.GetTargetIdMapping(), + RuleTypeMapping: m.GetRuleTypeMapping(), + TagMapping: m.GetTagMapping(), + AttributeNameMapping: m.GetAttributeNameMapping(), + AttributeStringValueMapping: m.GetAttributeStringValueMapping(), + } +} diff --git a/internal/mapper/target_graph_test.go b/internal/mapper/target_graph_test.go index 0d0d92fb..c07cd9f7 100644 --- a/internal/mapper/target_graph_test.go +++ b/internal/mapper/target_graph_test.go @@ -1,11 +1,14 @@ package mapper import ( + "context" + "fmt" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/uber/tango/core/targethasher" "github.com/uber/tango/entity" "github.com/uber/tango/tangopb" ) @@ -86,3 +89,113 @@ func TestProtoToGetTargetGraphRequest(t *testing.T) { }) } } + +func TestResultToTargetGraph(t *testing.T) { + t.Parallel() + + result := targethasher.Result{ + TargetNames: []string{"//pkg:a", "//pkg:b"}, + Targets: map[string]*targethasher.Target{ + "//pkg:a": {Name: "//pkg:a", Hash: []byte{0xab}, RuleType: "go_library", Deps: []string{"//pkg:b"}, Tags: []string{"manual"}, Root: true}, + "//pkg:b": {Name: "//pkg:b", Hash: []byte{0xcd}, RuleType: "go_test", External: true}, + }, + } + + graph, err := ResultToTargetGraph(context.Background(), result) + require.NoError(t, err) + require.Len(t, graph.Targets, 2) + + a := graph.Targets[0] + assert.Equal(t, "//pkg:a", a.Name) + assert.Equal(t, "ab", a.Hash) + assert.Equal(t, "go_library", a.RuleType) + assert.Equal(t, []string{"//pkg:b"}, a.Dependencies) + assert.Equal(t, []string{"manual"}, a.Tags) + assert.True(t, a.Root) + assert.False(t, a.External) + + b := graph.Targets[1] + assert.Equal(t, "//pkg:b", b.Name) + assert.Equal(t, "cd", b.Hash) + assert.True(t, b.External) +} + +func TestResultToTargetGraph_EmptyResult(t *testing.T) { + t.Parallel() + + graph, err := ResultToTargetGraph(context.Background(), targethasher.Result{}) + require.NoError(t, err) + assert.Empty(t, graph.Targets) +} + +func TestTargetGraphToProto_EmptyGraph(t *testing.T) { + t.Parallel() + + responses, err := TargetGraphToProto(context.Background(), entity.TargetGraph{}, 1<<20) + require.NoError(t, err) + require.Len(t, responses, 2) + + targets, ok := responses[0].Item.(*tangopb.GetTargetGraphResponse_Targets) + require.True(t, ok) + assert.Empty(t, targets.Targets.Targets) + + _, ok = responses[1].Item.(*tangopb.GetTargetGraphResponse_Metadata) + assert.True(t, ok) +} + +func TestTargetGraphToProto_Chunking(t *testing.T) { + t.Parallel() + + numTargets := 50 + result := targethasher.Result{ + TargetNames: make([]string, numTargets), + Targets: make(map[string]*targethasher.Target, numTargets), + } + for i := 0; i < numTargets; i++ { + name := fmt.Sprintf("//pkg:target%d", i) + result.TargetNames[i] = name + result.Targets[name] = &targethasher.Target{Name: name, Hash: []byte{0}, RuleType: "go_library"} + } + + graph, err := ResultToTargetGraph(context.Background(), result) + require.NoError(t, err) + + baseline, err := TargetGraphToProto(context.Background(), graph, 1<<30) + require.NoError(t, err) + var targetBytes int + for _, resp := range baseline { + if targets, ok := resp.Item.(*tangopb.GetTargetGraphResponse_Targets); ok { + targetBytes = targets.Targets.Targets[0].Size() + break + } + } + require.NotZero(t, targetBytes) + + tests := []struct { + name string + maxMessageBytes int + wantTargetChunks int + }{ + {name: "25 per chunk", maxMessageBytes: targetBytes * 25, wantTargetChunks: 2}, + {name: "10 per chunk", maxMessageBytes: targetBytes * 10, wantTargetChunks: 5}, + {name: "all in one chunk", maxMessageBytes: targetBytes * 100, wantTargetChunks: 1}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + responses, err := TargetGraphToProto(context.Background(), graph, tt.maxMessageBytes) + require.NoError(t, err) + + var targetChunks, totalTargets int + for _, resp := range responses { + if targets, ok := resp.Item.(*tangopb.GetTargetGraphResponse_Targets); ok { + targetChunks++ + totalTargets += len(targets.Targets.Targets) + } + } + assert.Equal(t, tt.wantTargetChunks, targetChunks) + assert.Equal(t, numTargets, totalTargets) + }) + } +} diff --git a/internal/streaming/BUILD.bazel b/internal/streaming/BUILD.bazel new file mode 100644 index 00000000..f6014da8 --- /dev/null +++ b/internal/streaming/BUILD.bazel @@ -0,0 +1,20 @@ +load("@rules_go//go:def.bzl", "go_library", "go_test") + +go_library( + name = "streaming", + srcs = ["streaming.go"], + importpath = "github.com/uber/tango/internal/streaming", + visibility = ["//:__subpackages__"], + deps = ["//entity"], +) + +go_test( + name = "streaming_test", + srcs = ["streaming_test.go"], + embed = [":streaming"], + deps = [ + "//tangopb", + "@com_github_stretchr_testify//assert", + "@com_github_stretchr_testify//require", + ], +) diff --git a/internal/streaming/streaming.go b/internal/streaming/streaming.go new file mode 100644 index 00000000..1f4072e9 --- /dev/null +++ b/internal/streaming/streaming.go @@ -0,0 +1,129 @@ +package streaming + +import ( + "fmt" + + "github.com/uber/tango/entity" +) + +// Sizer is satisfied by any type that reports its serialized byte length. +type Sizer interface { + Size() int +} + +// SplitBySize splits items into consecutive runs whose cumulative Size() +// stays at or under maxBytes. A single item larger than the budget ships +// alone since it can't be split further. Always returns at least one +// group: empty input yields a single empty group so callers always have a +// message to send on the stream. Returns an error if a multi-group split +// produces any empty group after the first. +func SplitBySize[T Sizer](items []T, maxBytes int) ([][]T, error) { + if len(items) == 0 { + return [][]T{nil}, nil + } + groups := make([][]T, 0, 1) + var current []T + currentBytes := 0 + for _, item := range items { + itemBytes := item.Size() + if len(current) > 0 && currentBytes+itemBytes > maxBytes { + groups = append(groups, current) + current = nil + currentBytes = 0 + } + current = append(current, item) + currentBytes += itemBytes + } + groups = append(groups, current) + for i := 1; i < len(groups); i++ { + if len(groups[i]) == 0 { + return nil, fmt.Errorf("internal error: group %d of %d is empty", i, len(groups)) + } + } + return groups, nil +} + +// SplitMetadata splits the metadata maps into multiple GraphMetadata +// messages so each stays at or under maxBytes. The two large maps (target +// names, attribute string values) are split independently by measured +// entry wire size; consumers merge all metadata messages before use. The +// small maps (rule_type, tag, attribute_name) are sent in the first +// message. Always returns at least one message. Returns an error if a +// non-first message is completely empty. +func SplitMetadata( + targetIDToName map[int32]string, + ruleTypeIDToName map[int32]string, + tagIDToName map[int32]string, + attrNameIDToName map[int32]string, + attrStrValIDToVal map[int32]string, + maxBytes int, +) ([]*entity.GraphMetadata, error) { + targetGroups := splitMapByBytes(targetIDToName, maxBytes) + attrValGroups := splitMapByBytes(attrStrValIDToVal, maxBytes) + + metas := make([]*entity.GraphMetadata, 0, max(1, len(targetGroups)+len(attrValGroups))) + for _, g := range targetGroups { + metas = append(metas, &entity.GraphMetadata{TargetIDMapping: g}) + } + for _, g := range attrValGroups { + metas = append(metas, &entity.GraphMetadata{AttributeStringValueMapping: g}) + } + if len(metas) == 0 { + metas = append(metas, &entity.GraphMetadata{}) + } + metas[0].RuleTypeMapping = ruleTypeIDToName + metas[0].TagMapping = tagIDToName + metas[0].AttributeNameMapping = attrNameIDToName + + for i := 1; i < len(metas); i++ { + m := metas[i] + if len(m.TargetIDMapping) == 0 && + len(m.RuleTypeMapping) == 0 && + len(m.TagMapping) == 0 && + len(m.AttributeNameMapping) == 0 && + len(m.AttributeStringValueMapping) == 0 { + return nil, fmt.Errorf("internal error: metadata group %d of %d is empty", i, len(metas)) + } + } + + return metas, nil +} + +func splitMapByBytes(m map[int32]string, maxBytes int) []map[int32]string { + if len(m) == 0 { + return nil + } + var groups []map[int32]string + current := make(map[int32]string) + currentBytes := 0 + for k, v := range m { + entryBytes := MapEntryWireSize(k, v) + if len(current) > 0 && currentBytes+entryBytes > maxBytes { + groups = append(groups, current) + current = make(map[int32]string) + currentBytes = 0 + } + current[k] = v + currentBytes += entryBytes + } + if len(current) > 0 { + groups = append(groups, current) + } + return groups +} + +// MapEntryWireSize returns the serialized size of one map[int32]string +// entry, mirroring the protobuf Metadata.Size() computation exactly. +func MapEntryWireSize(k int32, v string) int { + mapEntrySize := 1 + varintSize(uint64(k)) + 1 + len(v) + varintSize(uint64(len(v))) + return mapEntrySize + 1 + varintSize(uint64(mapEntrySize)) +} + +func varintSize(x uint64) int { + n := 1 + for x >= 0x80 { + x >>= 7 + n++ + } + return n +} diff --git a/internal/streaming/streaming_test.go b/internal/streaming/streaming_test.go new file mode 100644 index 00000000..f4335cee --- /dev/null +++ b/internal/streaming/streaming_test.go @@ -0,0 +1,114 @@ +package streaming + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + pb "github.com/uber/tango/tangopb" +) + +func TestSplitBySize(t *testing.T) { + t.Parallel() + + targets := make([]*pb.OptimizedTarget, 25) + for i := range targets { + targets[i] = &pb.OptimizedTarget{Id: int32(i + 1)} + } + maxBytes := targets[0].Size() * 10 + + groups, err := SplitBySize(targets, maxBytes) + require.NoError(t, err) + require.Len(t, groups, 3) + assert.Len(t, groups[0], 10) + assert.Len(t, groups[1], 10) + assert.Len(t, groups[2], 5) + + var total int + for _, g := range groups { + for _, target := range g { + assert.Equal(t, int32(total+1), target.Id) + total++ + } + } + assert.Equal(t, 25, total) +} + +func TestSplitBySize_SingleOversizedItemShipsAlone(t *testing.T) { + t.Parallel() + + oversized := &pb.OptimizedTarget{Id: 1, Hash: strings.Repeat("a", 1000)} + small := &pb.OptimizedTarget{Id: 2} + maxBytes := small.Size() + + groups, err := SplitBySize([]*pb.OptimizedTarget{oversized, small}, maxBytes) + require.NoError(t, err) + require.Len(t, groups, 2) + assert.Equal(t, []*pb.OptimizedTarget{oversized}, groups[0]) + assert.Equal(t, []*pb.OptimizedTarget{small}, groups[1]) +} + +func TestSplitBySize_EmptyInputReturnsOneEmptyGroup(t *testing.T) { + t.Parallel() + + groups, err := SplitBySize([]*pb.OptimizedTarget{}, 100) + require.NoError(t, err) + require.Len(t, groups, 1) + assert.Empty(t, groups[0]) +} + +func TestMapEntryWireSize_MatchesGeneratedSize(t *testing.T) { + t.Parallel() + + m := &pb.Metadata{TargetIdMapping: map[int32]string{7: "hello world"}} + assert.Equal(t, m.Size(), MapEntryWireSize(7, "hello world")) + + m2 := &pb.Metadata{TargetIdMapping: map[int32]string{1234567: strings.Repeat("x", 300)}} + assert.Equal(t, m2.Size(), MapEntryWireSize(1234567, strings.Repeat("x", 300))) +} + +func TestSplitMetadata_SplitsTargetMapByBytes(t *testing.T) { + t.Parallel() + + targetMap := map[int32]string{1: "a", 2: "b", 3: "c", 4: "d"} + ruleType := map[int32]string{1: "go_library"} + entryBytes := MapEntryWireSize(1, "a") + + metas, err := SplitMetadata(targetMap, ruleType, nil, nil, nil, entryBytes*2) + require.NoError(t, err) + require.Len(t, metas, 2) + + merged := map[int32]string{} + for i, meta := range metas { + for k, v := range meta.TargetIDMapping { + merged[k] = v + } + if i == 0 { + assert.Equal(t, ruleType, meta.RuleTypeMapping) + } else { + assert.Empty(t, meta.RuleTypeMapping) + } + } + assert.Equal(t, targetMap, merged) +} + +func TestSplitMetadata_AllEmptyMapsReturnsOneEmptyMessage(t *testing.T) { + t.Parallel() + + metas, err := SplitMetadata(nil, nil, nil, nil, nil, 100) + require.NoError(t, err) + require.Len(t, metas, 1) + assert.Empty(t, metas[0].TargetIDMapping) + assert.Empty(t, metas[0].RuleTypeMapping) +} + +func TestSplitMetadata_EmptyBigMapsStillCarrySmallMaps(t *testing.T) { + t.Parallel() + + ruleType := map[int32]string{1: "go_library"} + metas, err := SplitMetadata(nil, ruleType, nil, nil, nil, 100) + require.NoError(t, err) + require.Len(t, metas, 1) + assert.Equal(t, ruleType, metas[0].RuleTypeMapping) +}