Skip to content

Commit 910680c

Browse files
authored
refactor(conflict)!: key path overlap by file or by directory (#575)
## Summary ### Why? `fileoverlap` serialized two batches only when they changed the exact same file. That is the narrowest useful reading of target overlap, and for a queue whose directories are tightly coupled it is too narrow: two changes to sibling files in one package can break each other without either one touching the path the other did, and the analyzer will happily speculate them in parallel. The right granularity is not a property of the analyzer. It follows from how tightly coupled a directory's contents are in the repository behind the queue, which is something only the integrator wiring that queue up can know. So it belongs at construction, next to the resolver, rather than baked into the package. ### What? Overlap is measured on a key projected from each changed path. `PathKey` is that projection, chosen at construction and applied to every path before the two batches' sets are intersected; `Analyze` is otherwise unchanged. Two projections ship with the package. `ByFile` keys on the whole path and reproduces the previous behaviour. `ByDirectory` keys on the immediate parent, so batches touching sibling files conflict too — strictly coarser, since every file overlap is also a directory overlap. It buys protection against semantic conflicts between neighbouring files and pays for it in parallelism, which is the trade the integrator is choosing between. Paths at the repository root key on `.` under `ByDirectory`, so a batch touching `README.md` conflicts with one touching `go.mod`. Root files are usually build configuration and usually do interact, so this is deliberate rather than incidental. The package is renamed `fileoverlap` → `pathoverlap`, because the unit of overlap is now a path-derived key rather than a file. `New` takes the key as a third argument and panics on nil, mirroring `heuristic.New`. `conflict.Analyzer`, `conflict.Config` and `ConflictTypeTargetOverlap` are untouched — a folder is a coarser target, not a different kind of one. The only caller, `file-overlap-queue` in the orchestrator profiles, passes `ByFile` and keeps its behaviour and its name. One incidental behaviour change: `ByFile` runs `path.Clean`, where paths were previously compared verbatim. A provider emitting an unclean path used to produce a missed conflict. ## Test Plan ✅ `make test` — 98 pass ✅ `make lint`, `make check-gazelle`, `make check-tidy` New coverage in `pathoverlap_test.go`: - `TestPathKey` — both projections over a nested path, a repository-root file, and an unclean path. - Sibling files in one directory: no conflict under `ByFile`, conflict under `ByDirectory`; files in sibling directories conflict under neither; the same file still conflicts under both. - Two root-level files conflict under `ByDirectory` while a nested file in the same batch set does not. - `New` panics when the key is nil.
1 parent a514a8c commit 910680c

9 files changed

Lines changed: 266 additions & 128 deletions

File tree

doc/rfc/submitqueue/modular-queue-wiring.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -466,7 +466,7 @@ func run(ctx context.Context) error {
466466
ChangeProvider(github.New(cfg.GitHub)).
467467
BuildRunner(local.New()).
468468
Scorer(heuristic.New()).
469-
ConflictAnalyzer(fileoverlap.New()),
469+
ConflictAnalyzer(pathoverlap.New()),
470470
).
471471
Option(pipeline.TopicNames(cfg.TopicNames)).
472472
Option(pipeline.Classifiers(backendClassifiers())).
@@ -497,7 +497,7 @@ app, err := submitqueue.New().
497497
).
498498
Queue(base.Named("monorepo/exp").
499499
BuildRunner(local.New()).
500-
ConflictAnalyzer(fileoverlap.New()),
500+
ConflictAnalyzer(pathoverlap.New()),
501501
).
502502
Queue(base.Named("monorepo/test").
503503
BuildRunner(noop.New()).

service/submitqueue/orchestrator/server/BUILD.bazel

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,8 @@ go_library(
3737
"//submitqueue/extension/conflict:go_default_library",
3838
"//submitqueue/extension/conflict/all:go_default_library",
3939
"//submitqueue/extension/conflict/fake:go_default_library",
40-
"//submitqueue/extension/conflict/fileoverlap:go_default_library",
4140
"//submitqueue/extension/conflict/none:go_default_library",
41+
"//submitqueue/extension/conflict/pathoverlap:go_default_library",
4242
"//submitqueue/extension/scorer:go_default_library",
4343
"//submitqueue/extension/scorer/composite:go_default_library",
4444
"//submitqueue/extension/scorer/fake:go_default_library",

service/submitqueue/orchestrator/server/profiles.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,8 @@ import (
2727
"github.com/uber/submitqueue/submitqueue/extension/conflict"
2828
"github.com/uber/submitqueue/submitqueue/extension/conflict/all"
2929
conflictfake "github.com/uber/submitqueue/submitqueue/extension/conflict/fake"
30-
"github.com/uber/submitqueue/submitqueue/extension/conflict/fileoverlap"
3130
"github.com/uber/submitqueue/submitqueue/extension/conflict/none"
31+
"github.com/uber/submitqueue/submitqueue/extension/conflict/pathoverlap"
3232
"github.com/uber/submitqueue/submitqueue/extension/scorer"
3333
"github.com/uber/submitqueue/submitqueue/extension/scorer/composite"
3434
scorerfake "github.com/uber/submitqueue/submitqueue/extension/scorer/fake"
@@ -256,9 +256,10 @@ func newProfiles(logger *zap.Logger, scope tally.Scope, resolver changeset.Resol
256256

257257
// file-overlap-queue: a real analyzer that serializes only batches sharing
258258
// a changed file, resolving each batch's files itself via the resolver.
259+
// pathoverlap.ByDirectory would coarsen this to whole directories.
259260
fileOverlapQueue := base
260261
fileOverlapQueue.Analyzer = analyzerFunc(func(c conflict.Config) (conflict.Analyzer, error) {
261-
return fileoverlap.New(c, resolver), nil
262+
return pathoverlap.New(c, resolver, pathoverlap.ByFile), nil
262263
})
263264

264265
// e2e-test-queue: composite scorer; no conflicts (maximum parallelism).

submitqueue/extension/conflict/fileoverlap/README.md

Lines changed: 0 additions & 9 deletions
This file was deleted.

submitqueue/extension/conflict/fileoverlap/fileoverlap.go

Lines changed: 0 additions & 107 deletions
This file was deleted.

submitqueue/extension/conflict/fileoverlap/BUILD.bazel renamed to submitqueue/extension/conflict/pathoverlap/BUILD.bazel

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ load("@rules_go//go:def.bzl", "go_library", "go_test")
22

33
go_library(
44
name = "go_default_library",
5-
srcs = ["fileoverlap.go"],
6-
importpath = "github.com/uber/submitqueue/submitqueue/extension/conflict/fileoverlap",
5+
srcs = ["pathoverlap.go"],
6+
importpath = "github.com/uber/submitqueue/submitqueue/extension/conflict/pathoverlap",
77
visibility = ["//visibility:public"],
88
deps = [
99
"//submitqueue/core/changeset:go_default_library",
@@ -14,7 +14,7 @@ go_library(
1414

1515
go_test(
1616
name = "go_default_test",
17-
srcs = ["fileoverlap_test.go"],
17+
srcs = ["pathoverlap_test.go"],
1818
embed = [":go_default_library"],
1919
deps = [
2020
"//submitqueue/core/changeset/fake:go_default_library",
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# pathoverlap
2+
3+
`pathoverlap` is a `conflict.Analyzer` that reports a conflict between two batches when the paths they change share a key.
4+
5+
## Behavior
6+
7+
The files a batch changes are drawn from each change's provider-supplied details, and each path is projected onto a key by the `PathKey` chosen at construction. The candidate batch conflicts with an in-flight batch when their key sets intersect; each such in-flight batch is reported once, preserving the in-flight order. A shared path key is the concrete notion of *target overlap*, so conflicts are classified as `ConflictTypeTargetOverlap`. A batch that changes no files conflicts with nothing, and an empty in-flight list yields no conflicts. A failure to resolve a batch's changes is returned as a (retryable) error.
8+
9+
## Granularity
10+
11+
Two projections ship with the package, selected per queue in the wiring layer:
12+
13+
- **`ByFile`** keys on the whole path, so only batches touching the same file conflict.
14+
- **`ByDirectory`** keys on the path's immediate parent directory, so batches touching sibling files conflict too. Paths at the repository root share the key `.`, which serializes batches that touch any two root-level files.
15+
16+
`ByDirectory` is strictly coarser: every file overlap is also a directory overlap. It trades parallelism for protection against semantic conflicts between neighbouring files — edits that break each other without touching the same file. Which trade is right is a per-queue judgement about how tightly coupled a directory's contents are, so the choice is a construction parameter rather than a property of the analyzer.
17+
18+
Path-key intersection is a deliberately simple notion of overlap. A richer one that needs inputs beyond the changed paths — build targets, ownership boundaries — would be a separate analyzer rather than another `PathKey`.
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
// Copyright (c) 2025 Uber Technologies, Inc.
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 pathoverlap provides a conflict.Analyzer that reports a conflict
16+
// between two batches when the paths they change share a key. The key is a
17+
// projection of the path chosen at construction: ByFile compares whole paths,
18+
// so only batches touching the same file conflict; ByDirectory compares parent
19+
// directories, so batches touching sibling files conflict too. It is the first
20+
// analyzer to use the capability the extension contract unblocks: it takes only
21+
// batch identity and resolves each batch's changed files itself through an
22+
// injected changeset resolver, rather than depending on the controller to
23+
// pre-compute them. A shared path key is the concrete notion of target overlap,
24+
// so it reports entity.ConflictTypeTargetOverlap.
25+
package pathoverlap
26+
27+
import (
28+
"context"
29+
"fmt"
30+
"path"
31+
32+
"github.com/uber/submitqueue/submitqueue/core/changeset"
33+
"github.com/uber/submitqueue/submitqueue/entity"
34+
"github.com/uber/submitqueue/submitqueue/extension/conflict"
35+
)
36+
37+
// PathKey projects a changed file path onto the key overlap is measured on. Two
38+
// batches conflict when any of their changed paths yield the same key, so a
39+
// coarser projection serializes more batches.
40+
type PathKey func(path string) string
41+
42+
// ByFile keys on the whole path: batches conflict only when they change the
43+
// same file.
44+
func ByFile(p string) string {
45+
return path.Clean(p)
46+
}
47+
48+
// ByDirectory keys on the path's immediate parent directory: batches conflict
49+
// when they change any files in the same directory, whether or not the files
50+
// themselves are the same. Overlap by directory is strictly coarser than
51+
// overlap by file — every file overlap is also a directory overlap. Paths at
52+
// the repository root share the key ".".
53+
func ByDirectory(p string) string {
54+
return path.Dir(p)
55+
}
56+
57+
// analyzer reports a conflict between batches whose changed paths share a key.
58+
// The paths a batch changes are resolved from each batch's change details.
59+
type analyzer struct {
60+
// cfg is the per-queue identity this analyzer was built for.
61+
cfg conflict.Config
62+
resolver changeset.Resolver
63+
// key projects each changed path onto the key overlap is measured on.
64+
key PathKey
65+
}
66+
67+
// New returns a conflict.Analyzer that flags an in-flight batch as conflicting
68+
// when it changes a path whose key matches one the candidate batch changes,
69+
// bound to the queue named in cfg. The resolver resolves each batch's changed
70+
// files, and key selects the granularity of overlap.
71+
// Panics if key is nil.
72+
func New(cfg conflict.Config, resolver changeset.Resolver, key PathKey) conflict.Analyzer {
73+
if key == nil {
74+
panic("pathoverlap.New: key must not be nil")
75+
}
76+
return analyzer{cfg: cfg, resolver: resolver, key: key}
77+
}
78+
79+
// Analyze returns one ConflictTypeTargetOverlap Conflict per in-flight batch
80+
// that shares a path key with batch, preserving the in-flight order. A batch
81+
// that changes no files conflicts with nothing.
82+
func (a analyzer) Analyze(ctx context.Context, batch entity.Batch, inFlight []entity.Batch) ([]entity.Conflict, error) {
83+
if len(inFlight) == 0 {
84+
return nil, nil
85+
}
86+
87+
candidate, err := a.keys(ctx, batch)
88+
if err != nil {
89+
return nil, fmt.Errorf("failed to resolve files for batch %s: %w", batch.ID, err)
90+
}
91+
if len(candidate) == 0 {
92+
return nil, nil
93+
}
94+
95+
var conflicts []entity.Conflict
96+
for _, other := range inFlight {
97+
keys, err := a.keys(ctx, other)
98+
if err != nil {
99+
return nil, fmt.Errorf("failed to resolve files for batch %s: %w", other.ID, err)
100+
}
101+
if intersects(candidate, keys) {
102+
conflicts = append(conflicts, entity.Conflict{
103+
BatchID: other.ID,
104+
Type: entity.ConflictTypeTargetOverlap,
105+
})
106+
}
107+
}
108+
return conflicts, nil
109+
}
110+
111+
// keys resolves the set of path keys the batch changes.
112+
func (a analyzer) keys(ctx context.Context, batch entity.Batch) (map[string]struct{}, error) {
113+
changes, err := a.resolver.DetailedForBatch(ctx, batch)
114+
if err != nil {
115+
return nil, err
116+
}
117+
keys := make(map[string]struct{})
118+
for _, change := range changes.Changes {
119+
for _, file := range change.Details.ChangedFiles {
120+
keys[a.key(file.Path)] = struct{}{}
121+
}
122+
}
123+
return keys, nil
124+
}
125+
126+
// intersects reports whether the two sets share any element.
127+
func intersects(a, b map[string]struct{}) bool {
128+
// Iterate the smaller set for fewer lookups.
129+
if len(b) < len(a) {
130+
a, b = b, a
131+
}
132+
for k := range a {
133+
if _, ok := b[k]; ok {
134+
return true
135+
}
136+
}
137+
return false
138+
}

0 commit comments

Comments
 (0)