Skip to content

Commit 2191ba1

Browse files
committed
perf: use a custom trie for path conflict validation
1 parent ebec450 commit 2191ba1

5 files changed

Lines changed: 619 additions & 32 deletions

File tree

internal/setup/conflict.go

Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
package setup
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"maps"
7+
"slices"
8+
"strings"
9+
10+
"github.com/canonical/chisel/internal/strdist"
11+
)
12+
13+
type segmentSlice struct {
14+
Slice *Slice
15+
// PathInfo is kept here as an optimzation to avoid lookups on
16+
// Slice.Contents for every slice.
17+
PathInfo PathInfo
18+
// WholePath is used to simplify both error reporting and matching against
19+
// paths with "**"; both of which require reconstructing the whole path.
20+
WholePath string
21+
}
22+
23+
type segment struct {
24+
Text string
25+
// HasGlob is set when the path contains "*" or "?" or "**".
26+
HasGlob bool
27+
// HasDoubleGlob is set when the path contains "**".
28+
HasDoubleGlob bool
29+
}
30+
31+
type node struct {
32+
Segment segment
33+
Slices []segmentSlice
34+
Children map[string]*node
35+
}
36+
37+
// pathConflictTree uses a custom trie to find conflicts that might arise from
38+
// extracting different paths into the same root directory.
39+
//
40+
// It optimizes conflict resolution by calling strdist.GlobPath only when
41+
// strictly necessary and by passing it less data to compare. It relies on the
42+
// fact that real chisel releases most paths often share a very long prefix
43+
// that does not need to be compared each time. Additionally, our grammar is
44+
// very restrictive (only "*", "?" and "**") meaning that unless "**" is used,
45+
// any symbol can only match until a "/" is found.
46+
//
47+
// Because of the above, this algorithms splits paths into segments that are
48+
// delimited by "/". When inserting a path, each segment is compared at most
49+
// once with the path independently of how many paths there are in the release.
50+
// Lastly, when looking for conflicts, if the segments do not contain "**" then
51+
// instead of comparing the whole path we can compare only the segment.
52+
type pathConflictTree struct {
53+
Root *node
54+
PathToSlices map[string][]*Slice
55+
}
56+
57+
func newConflictTree(pathToSlices map[string][]*Slice) pathConflictTree {
58+
root := &node{
59+
Segment: segment{"/", false, false},
60+
Children: map[string]*node{},
61+
}
62+
return pathConflictTree{Root: root, PathToSlices: pathToSlices}
63+
}
64+
65+
func (g *pathConflictTree) HasConflict() error {
66+
for path, slices := range g.PathToSlices {
67+
var oldInfos []segmentSlice
68+
for _, oldSlice := range slices {
69+
oldInfos = append(oldInfos, segmentSlice{oldSlice, oldSlice.Contents[path], path})
70+
}
71+
segments, err := pathToSegments(path)
72+
if err != nil {
73+
return err
74+
}
75+
err = g.pathHasConflict(path, segments, oldInfos)
76+
if err != nil {
77+
return err
78+
}
79+
g.insertSegments(segments, oldInfos)
80+
}
81+
return nil
82+
}
83+
84+
func (g *pathConflictTree) pathHasConflict(oldPath string, oldSegments []segment, oldInfos []segmentSlice) error {
85+
conflictErrMsg := func(oldInfo, newInfo *segmentSlice) error {
86+
oldSlice, oldPath := oldInfo.Slice, oldInfo.WholePath
87+
newSlice, newPath := newInfo.Slice, newInfo.WholePath
88+
if (oldSlice.Package > newSlice.Package) || (oldSlice.Package == newSlice.Package && oldSlice.Name > newSlice.Name) ||
89+
(oldSlice.Package == newSlice.Package && oldSlice.Name == newSlice.Name && oldPath > newPath) {
90+
oldSlice, newSlice = newSlice, oldSlice
91+
oldPath, newPath = newPath, oldPath
92+
}
93+
return fmt.Errorf("slices %s and %s conflict on %s and %s", oldSlice, newSlice, oldPath, newPath)
94+
}
95+
96+
var currentQueue []*node
97+
var nextQueue []*node
98+
99+
// Skip "/".
100+
currentQueue = slices.Collect(maps.Values(g.Root.Children))
101+
oldSegments = oldSegments[1:]
102+
103+
for len(currentQueue) > 0 {
104+
oldSegment := oldSegments[0]
105+
for _, newNode := range currentQueue {
106+
newNodeLoop:
107+
for _, oldSegmentInfo := range oldInfos {
108+
oldSlice := oldSegmentInfo.Slice
109+
oldPathInfo := oldSegmentInfo.PathInfo
110+
for _, newSegmentInfo := range newNode.Slices {
111+
newSlice := newSegmentInfo.Slice
112+
newPathInfo := newSegmentInfo.PathInfo
113+
newSegment := newNode.Segment
114+
115+
// If slices cannot conflict then skip the more expensive
116+
// checks.
117+
if (oldPathInfo.Kind == GlobPath || oldPathInfo.Kind == CopyPath) && (newPathInfo.Kind == GlobPath || newPathInfo.Kind == CopyPath) {
118+
if newSlice.Package == oldSlice.Package {
119+
// If content is **extracted** from the same
120+
// package, it will necessarily be the same.
121+
continue
122+
}
123+
}
124+
125+
if newSegment.HasDoubleGlob || oldSegment.HasDoubleGlob {
126+
// Case 1: One of the strings has a double glob, we
127+
// need to check the whole remaining path against
128+
// each other.
129+
if strdist.GlobPath(oldSegmentInfo.WholePath, newSegmentInfo.WholePath) {
130+
return conflictErrMsg(&oldSegmentInfo, &newSegmentInfo)
131+
}
132+
} else if newSegment.HasGlob || oldSegment.HasGlob {
133+
// Case 2: Either segment has a single glob (* or ?).
134+
// We only need to check the segment.
135+
if strdist.GlobPath(oldSegment.Text, newSegment.Text) {
136+
// Only when we get to leaf (i.e. no children, can
137+
// we have a conflict).
138+
if len(newNode.Children) == 0 {
139+
if len(oldSegments) == 1 {
140+
// If we are at the terminal node of both paths we found a conflict.
141+
return conflictErrMsg(&oldSegmentInfo, &newSegmentInfo)
142+
} else {
143+
// If oldPath is not yet finished we will keep comparing it against
144+
// this segment. Example: ["/", "a/", "*", ""] and ["/", "a/", ""];
145+
// the segments ["*", ""] match [""].
146+
nextQueue = append(nextQueue, newNode)
147+
}
148+
}
149+
for _, child := range newNode.Children {
150+
nextQueue = append(nextQueue, child)
151+
}
152+
break newNodeLoop
153+
} else {
154+
// Once GlobPath returns false there cannot be a
155+
// conflict between oldPath and newPath, we can
156+
// break here.
157+
break newNodeLoop
158+
}
159+
} else {
160+
// Case 3: No globs, we can compare the strings directly.
161+
if oldSegment.Text == newSegment.Text {
162+
if len(newNode.Children) == 0 && len(oldSegments) == 1 {
163+
// If these are both terminal nodes, conflict found.
164+
return conflictErrMsg(&oldSegmentInfo, &newSegmentInfo)
165+
}
166+
for _, child := range newNode.Children {
167+
nextQueue = append(nextQueue, child)
168+
}
169+
break newNodeLoop
170+
}
171+
}
172+
}
173+
}
174+
}
175+
currentQueue, nextQueue = nextQueue, currentQueue
176+
nextQueue = nextQueue[0:0]
177+
178+
if len(oldSegments) > 1 {
179+
// If the segment is a termination node keep it. See example in case 2.
180+
oldSegments = oldSegments[1:]
181+
}
182+
}
183+
184+
return nil
185+
}
186+
187+
// insertSegments inserts the path's segments blindly in the graph without
188+
// looking at conflicts.
189+
func (g *pathConflictTree) insertSegments(segments []segment, infos []segmentSlice) {
190+
parent := g.Root
191+
// Skip "/".
192+
segments = segments[1:]
193+
194+
for _, segment := range segments {
195+
current, ok := parent.Children[segment.Text]
196+
if !ok {
197+
current = &node{
198+
Segment: segment,
199+
Children: map[string]*node{},
200+
}
201+
}
202+
current.Slices = append(current.Slices, infos...)
203+
parent.Children[segment.Text] = current
204+
parent = current
205+
}
206+
}
207+
208+
// pathToSegments returns the list of segments that compose the path plus the
209+
// empty segment "" for explicit termination in the trie.
210+
func pathToSegments(path string) ([]segment, error) {
211+
if path[0] != '/' {
212+
return nil, errors.New("internal error: path does not start with '/'")
213+
}
214+
segments := []segment{segment{"/", false, false}}
215+
path = path[1:]
216+
for {
217+
end, singleGlob, doubleGlob := segmentEnd(path)
218+
segment := segment{
219+
Text: path[:end+1],
220+
HasGlob: singleGlob,
221+
HasDoubleGlob: doubleGlob,
222+
}
223+
segments = append(segments, segment)
224+
path = path[end+1:]
225+
if segment.Text == "" {
226+
break
227+
}
228+
}
229+
return segments, nil
230+
}
231+
232+
// segmentEnd finds the end of a segment according to the following rules:
233+
// - If s contains "**" then segment = s.
234+
// - Else if the s contains "/" then segment will finish at the first "/"
235+
// found.
236+
// - Else segment = s.
237+
//
238+
// hasGlob is set to true if "*", "?" or "**" is found in the segment.
239+
// hasDoubleGlob is set to true if "**" is found in the segment.
240+
func segmentEnd(s string) (end int, hasGlob bool, hasDoubleGlob bool) {
241+
end = strings.IndexAny(s, "*?/")
242+
if end == -1 {
243+
end = len(s) - 1
244+
} else if s[end] == '*' || s[end] == '?' {
245+
hasGlob = true
246+
slash := strings.IndexRune(s[end:], '/')
247+
if slash != -1 {
248+
end = end + slash
249+
} else {
250+
end = len(s) - 1
251+
}
252+
hasDoubleGlob = strings.Contains(s[:end+1], "**")
253+
if hasDoubleGlob {
254+
end = len(s) - 1
255+
}
256+
}
257+
return end, hasGlob, hasDoubleGlob
258+
}

0 commit comments

Comments
 (0)