diff --git a/submitqueue/extension/conflict/tango/BUILD.bazel b/submitqueue/extension/conflict/tango/BUILD.bazel index 10f193dd..e9064c34 100644 --- a/submitqueue/extension/conflict/tango/BUILD.bazel +++ b/submitqueue/extension/conflict/tango/BUILD.bazel @@ -1,9 +1,10 @@ -load("@rules_go//go:def.bzl", "go_library") +load("@rules_go//go:def.bzl", "go_library", "go_test") go_library( name = "go_default_library", srcs = [ "mock_store.go", + "signature.go", "store.go", "target_label.go", "target_signature.go", @@ -12,3 +13,10 @@ go_library( visibility = ["//visibility:public"], deps = ["@org_uber_go_mock//gomock:go_default_library"], ) + +go_test( + name = "go_default_test", + srcs = ["signature_test.go"], + embed = [":go_default_library"], + deps = ["@com_github_stretchr_testify//assert:go_default_library"], +) diff --git a/submitqueue/extension/conflict/tango/signature.go b/submitqueue/extension/conflict/tango/signature.go new file mode 100644 index 00000000..c59d6504 --- /dev/null +++ b/submitqueue/extension/conflict/tango/signature.go @@ -0,0 +1,88 @@ +// Copyright (c) 2026 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package tango converts Tango's changed-target responses into target +// signatures and defines the signature and dictionary records. +package tango + +import ( + "cmp" + "crypto/sha256" + "encoding/binary" + "slices" +) + +// targetIDBytes is how much of a label's SHA-256 digest forms its target ID. +// 8 bytes fit a uint64; a collision only adds a false conflict. +const targetIDBytes = 8 + +// LabelDistance is one affected target: its label exactly as Tango reported it +// and its distance from the nearest directly changed source. +type LabelDistance struct { + // Label is the target label, unmodified from Tango's response. + Label string + + // Distance is 0 for a target a change touches directly and grows by one for each dependency edge away from one. + Distance int32 +} + +// hashedLabel records a label under the target ID it hashes to. +type hashedLabel struct { + id uint64 + label string +} + +type hashedTarget struct { + hashedLabel + distance int32 +} + +// targetID returns the first 8 bytes of the SHA-256 of label, read big-endian. +func targetID(label string) uint64 { + digest := sha256.Sum256([]byte(label)) + return binary.BigEndian.Uint64(digest[:targetIDBytes]) +} + +// toSignatureTargets returns the signature targets sorted ascending by ID, one +// per ID at the smallest distance reported for it, and every distinct (ID, +// label) pair sorted by ID then label. targets is not modified. An empty input +// yields nil results, which is a valid signature that conflicts with nothing. +func toSignatureTargets(targets []LabelDistance) ([]SignatureTarget, []hashedLabel) { + hashed := make([]hashedTarget, len(targets)) + for i, target := range targets { + hashed[i] = hashedTarget{hashedLabel: hashedLabel{id: targetID(target.Label), label: target.Label}, distance: target.Distance} + } + return mergeHashedTargetsByID(hashed) +} + +// mergeHashedTargetsByID sorts hashed in place; callers hand over ownership. +func mergeHashedTargetsByID(hashed []hashedTarget) ([]SignatureTarget, []hashedLabel) { + slices.SortFunc(hashed, func(a, b hashedTarget) int { + return cmp.Or(cmp.Compare(a.id, b.id), cmp.Compare(a.label, b.label)) + }) + + var signatureTargets []SignatureTarget + var labels []hashedLabel + for _, target := range hashed { + if n := len(labels); n == 0 || labels[n-1] != target.hashedLabel { + labels = append(labels, target.hashedLabel) + } + if n := len(signatureTargets); n > 0 && signatureTargets[n-1].ID == target.id { + signatureTargets[n-1].Distance = min(signatureTargets[n-1].Distance, target.distance) + continue + } + signatureTargets = append(signatureTargets, SignatureTarget{ID: target.id, Distance: target.distance}) + } + return signatureTargets, labels +} diff --git a/submitqueue/extension/conflict/tango/signature_test.go b/submitqueue/extension/conflict/tango/signature_test.go new file mode 100644 index 00000000..ce566866 --- /dev/null +++ b/submitqueue/extension/conflict/tango/signature_test.go @@ -0,0 +1,171 @@ +// Copyright (c) 2026 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package tango + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +const ( + legacyLabel = "//lib/strutil:legacy" + strutilLabel = "//lib/strutil:strutil" + fuzzTestLabel = "//lib/strutil:fuzz_test" + apiLabel = "//svc/api:api" + apiTestLabel = "//svc/api:api_test" + + legacyID = uint64(0x735cc8dafbb93f02) + strutilID = uint64(0x90afc9166e08f639) + fuzzTestID = uint64(0xa9becee9e924a370) + apiID = uint64(0xbb211c1937bbeab8) + apiTestID = uint64(0xc48fa23bb87b90f4) +) + +func TestTargetID(t *testing.T) { + tests := []struct { + label string + want uint64 + }{ + {legacyLabel, legacyID}, + {strutilLabel, strutilID}, + {fuzzTestLabel, fuzzTestID}, + {apiLabel, apiID}, + {apiTestLabel, apiTestID}, + } + for _, tt := range tests { + t.Run(tt.label, func(t *testing.T) { + assert.Equal(t, tt.want, targetID(tt.label)) + }) + } +} + +func TestToSignatureTargets(t *testing.T) { + tests := []struct { + name string + targets []LabelDistance + wantTarget []SignatureTarget + wantLabels []hashedLabel + }{ + { + name: "empty input is an empty signature", + targets: nil, + wantTarget: nil, + wantLabels: nil, + }, + { + name: "single target", + targets: []LabelDistance{{Label: apiLabel, Distance: 1}}, + wantTarget: []SignatureTarget{{ID: apiID, Distance: 1}}, + wantLabels: []hashedLabel{{id: apiID, label: apiLabel}}, + }, + { + name: "response order is sorted by ID", + targets: []LabelDistance{ + {Label: strutilLabel, Distance: 0}, + {Label: apiLabel, Distance: 1}, + {Label: apiTestLabel, Distance: 2}, + {Label: fuzzTestLabel, Distance: 0}, + {Label: legacyLabel, Distance: 0}, + }, + wantTarget: []SignatureTarget{ + {ID: legacyID, Distance: 0}, + {ID: strutilID, Distance: 0}, + {ID: fuzzTestID, Distance: 0}, + {ID: apiID, Distance: 1}, + {ID: apiTestID, Distance: 2}, + }, + wantLabels: []hashedLabel{ + {id: legacyID, label: legacyLabel}, + {id: strutilID, label: strutilLabel}, + {id: fuzzTestID, label: fuzzTestLabel}, + {id: apiID, label: apiLabel}, + {id: apiTestID, label: apiTestLabel}, + }, + }, + { + name: "a repeated label keeps the smaller distance", + targets: []LabelDistance{ + {Label: apiLabel, Distance: 3}, + {Label: apiLabel, Distance: 1}, + {Label: apiLabel, Distance: 2}, + }, + wantTarget: []SignatureTarget{{ID: apiID, Distance: 1}}, + wantLabels: []hashedLabel{{id: apiID, label: apiLabel}}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotTargets, gotLabels := toSignatureTargets(tt.targets) + + assert.Equal(t, tt.wantTarget, gotTargets) + assert.Equal(t, tt.wantLabels, gotLabels) + }) + } +} + +func TestToSignatureTargets_LeavesInputUnchanged(t *testing.T) { + targets := []LabelDistance{ + {Label: apiTestLabel, Distance: 2}, + {Label: legacyLabel, Distance: 0}, + {Label: apiLabel, Distance: 1}, + } + original := append([]LabelDistance(nil), targets...) + + toSignatureTargets(targets) + + assert.Equal(t, original, targets) +} + +func TestMergeHashedTargetsByID(t *testing.T) { + tests := []struct { + name string + hashed []hashedTarget + wantTarget []SignatureTarget + wantLabels []hashedLabel + }{ + { + name: "labels sharing an ID collapse to the smaller distance and both stay in the dictionary", + hashed: []hashedTarget{ + {hashedLabel: hashedLabel{id: 7, label: "//b:y"}, distance: 2}, + {hashedLabel: hashedLabel{id: 7, label: "//a:x"}, distance: 5}, + {hashedLabel: hashedLabel{id: 3, label: "//c:z"}, distance: 0}, + }, + wantTarget: []SignatureTarget{{ID: 3, Distance: 0}, {ID: 7, Distance: 2}}, + wantLabels: []hashedLabel{ + {id: 3, label: "//c:z"}, + {id: 7, label: "//a:x"}, + {id: 7, label: "//b:y"}, + }, + }, + { + name: "colliding labels with equal distances keep one signature entry", + hashed: []hashedTarget{ + {hashedLabel: hashedLabel{id: 9, label: "//b:y"}, distance: 1}, + {hashedLabel: hashedLabel{id: 9, label: "//a:x"}, distance: 1}, + }, + wantTarget: []SignatureTarget{{ID: 9, Distance: 1}}, + wantLabels: []hashedLabel{{id: 9, label: "//a:x"}, {id: 9, label: "//b:y"}}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotTargets, gotLabels := mergeHashedTargetsByID(tt.hashed) + + assert.Equal(t, tt.wantTarget, gotTargets) + assert.Equal(t, tt.wantLabels, gotLabels) + }) + } +}