Skip to content

Commit ff776f6

Browse files
authored
campaigns: skip changeset spec creation for cached empty diffs (#397)
* campaigns: skip changeset spec creation for cached empty diffs It's totally valid and normal for empty diffs to be created when executing campaign specs: sometimes you just don't want anything to change, even though the repo matched the initial query. When this happens, #313 added a check that prevents the changeset spec from being created, and print a verbose mode message indicating that the repo was skipped: https://sourcegraph.com/github.com/sourcegraph/src-cli@d29ad54eff678d96fb7ebdf75ff95890dce6a1cf/-/blob/internal/campaigns/executor.go?utm_source=VSCode-1.1.0#L273-278 So far, so good. In #374, we made our empty diff handling even better by caching the empty diff: this means that we don't have to recalculate that nothing happened. Unfortunately, the check that exists in the cache miss code path to skip changeset spec creation doesn't exist in the cache hit code path, which means that on subsequent applications of the campaign, a changeset spec with an empty diff will be uploaded, and gitserver will ultimately be very grumpy. By applying the same logic to the cache hit code path, we can filter out these problematic changeset specs. * Extend integration tests to cover the empty diff bug. This also means that we run all the integration tests with cold and warm caches, which should help pick up these issues in future.
1 parent d29ad54 commit ff776f6

File tree

3 files changed

+181
-46
lines changed

3 files changed

+181
-46
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ All notable changes to `src-cli` are documented in this file.
2121
### Fixed
2222

2323
- The evaluation of the [`repository.branch` attribute](https://docs.sourcegraph.com/campaigns/references/campaign_spec_yaml_reference#on-repository) has been fixed to actually cause the correct version of the repository to be used. [#393](https://github.com/sourcegraph/src-cli/pull/393)
24+
- Normally, when one or more repositories in a campaign generate an empty diff, a changeset spec isn't created. From src-cli 3.21.9 to 3.22.3, inclusive, re-running a campaign would result in an empty changeset spec being created by mistake if the empty changeset spec was in the execution cache, which would result in errors on Sourcegraph when applying the campaign. This has been fixed, and empty changeset specs in the cache are now treated the same way as uncached changeset specs that are empty: they are skipped, and a message is displayed in `-v` mode indicating the repo that was skipped. [#397](https://github.com/sourcegraph/src-cli/pull/397)
2425

2526
### Removed
2627

internal/campaigns/executor.go

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -210,9 +210,20 @@ func (x *executor) do(ctx context.Context, task *Task) (err error) {
210210
diff = result.Commits[0].Diff
211211
}
212212

213+
status.Cached = true
214+
215+
// If the cached result resulted in an empty diff, we don't need to
216+
// add it to the list of specs that are displayed to the user and
217+
// send to the server. Instead, we can just report that the task is
218+
// complete and move on.
219+
if len(diff) == 0 {
220+
status.FinishedAt = time.Now()
221+
x.updateTaskStatus(task, status)
222+
return
223+
}
224+
213225
spec := createChangesetSpec(task, diff, x.features)
214226

215-
status.Cached = true
216227
status.ChangesetSpec = spec
217228
status.FinishedAt = time.Now()
218229
x.updateTaskStatus(task, status)

internal/campaigns/executor_test.go

Lines changed: 168 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"archive/zip"
55
"bytes"
66
"context"
7+
"encoding/json"
78
"fmt"
89
"io/ioutil"
910
"log"
@@ -14,6 +15,7 @@ import (
1415
"path/filepath"
1516
"runtime"
1617
"strings"
18+
"sync"
1719
"testing"
1820
"time"
1921

@@ -116,6 +118,21 @@ func TestExecutor_Integration(t *testing.T) {
116118
srcCLIRepo.ID: []string{"main.go", "modified-main.go.md", "added-modified-main.go.md"},
117119
},
118120
},
121+
{
122+
name: "empty",
123+
repos: []*graphql.Repository{srcCLIRepo},
124+
archives: []mockRepoArchive{
125+
{repo: srcCLIRepo, files: map[string]string{
126+
"README.md": "# Welcome to the README\n",
127+
"main.go": "package main\n\nfunc main() {\n\tfmt.Println( \"Hello World\")\n}\n",
128+
}},
129+
},
130+
steps: []Step{
131+
{Run: `true`, Container: "doesntmatter:13"},
132+
},
133+
// No changesets should be generated.
134+
wantFilesChanged: map[string][]string{},
135+
},
119136
}
120137

121138
for _, tc := range tests {
@@ -132,9 +149,10 @@ func TestExecutor_Integration(t *testing.T) {
132149
}
133150
defer os.Remove(testTempDir)
134151

152+
cache := newInMemoryExecutionCache()
135153
creator := &WorkspaceCreator{dir: testTempDir, client: client}
136154
opts := ExecutorOpts{
137-
Cache: &ExecutionNoOpCache{},
155+
Cache: cache,
138156
Creator: creator,
139157
TempDir: testTempDir,
140158
Parallelism: runtime.GOMAXPROCS(0),
@@ -144,63 +162,98 @@ func TestExecutor_Integration(t *testing.T) {
144162
opts.Timeout = 30 * time.Second
145163
}
146164

147-
executor := newExecutor(opts, client, featuresAllEnabled())
165+
// execute contains the actual logic running the tasks on an
166+
// executor. We'll run this multiple times to cover both the cache
167+
// and non-cache code paths.
168+
execute := func() {
169+
executor := newExecutor(opts, client, featuresAllEnabled())
148170

149-
template := &ChangesetTemplate{}
150-
for _, r := range tc.repos {
151-
executor.AddTask(r, tc.steps, template)
152-
}
153-
154-
executor.Start(context.Background())
155-
specs, err := executor.Wait()
156-
if tc.wantErrInclude == "" && err != nil {
157-
t.Fatalf("execution failed: %s", err)
158-
}
159-
if err != nil && !strings.Contains(err.Error(), tc.wantErrInclude) {
160-
t.Errorf("wrong error. have=%q want included=%q", err, tc.wantErrInclude)
161-
}
162-
if tc.wantErrInclude != "" {
163-
return
164-
}
165-
166-
if have, want := len(specs), len(tc.wantFilesChanged); have != want {
167-
t.Fatalf("wrong number of changeset specs. want=%d, have=%d", want, have)
168-
}
169-
170-
for _, spec := range specs {
171-
if have, want := len(spec.Commits), 1; have != want {
172-
t.Fatalf("wrong number of commits. want=%d, have=%d", want, have)
171+
template := &ChangesetTemplate{}
172+
for _, r := range tc.repos {
173+
executor.AddTask(r, tc.steps, template)
173174
}
174175

175-
fileDiffs, err := diff.ParseMultiFileDiff([]byte(spec.Commits[0].Diff))
176-
if err != nil {
177-
t.Fatalf("failed to parse diff: %s", err)
176+
executor.Start(context.Background())
177+
specs, err := executor.Wait()
178+
if tc.wantErrInclude == "" && err != nil {
179+
t.Fatalf("execution failed: %s", err)
178180
}
179-
180-
wantFiles, ok := tc.wantFilesChanged[spec.BaseRepository]
181-
if !ok {
182-
t.Fatalf("unexpected file changes in repo %s", spec.BaseRepository)
181+
if err != nil && !strings.Contains(err.Error(), tc.wantErrInclude) {
182+
t.Errorf("wrong error. have=%q want included=%q", err, tc.wantErrInclude)
183+
}
184+
if tc.wantErrInclude != "" {
185+
return
183186
}
184187

185-
if have, want := len(fileDiffs), len(wantFiles); have != want {
186-
t.Fatalf("repo %s: wrong number of fileDiffs. want=%d, have=%d", spec.BaseRepository, want, have)
188+
if have, want := len(specs), len(tc.wantFilesChanged); have != want {
189+
t.Fatalf("wrong number of changeset specs. want=%d, have=%d", want, have)
187190
}
188191

189-
diffsByName := map[string]*diff.FileDiff{}
190-
for _, fd := range fileDiffs {
191-
if fd.NewName == "/dev/null" {
192-
diffsByName[fd.OrigName] = fd
193-
} else {
194-
diffsByName[fd.NewName] = fd
192+
for _, spec := range specs {
193+
if have, want := len(spec.Commits), 1; have != want {
194+
t.Fatalf("wrong number of commits. want=%d, have=%d", want, have)
195+
}
196+
197+
fileDiffs, err := diff.ParseMultiFileDiff([]byte(spec.Commits[0].Diff))
198+
if err != nil {
199+
t.Fatalf("failed to parse diff: %s", err)
200+
}
201+
202+
wantFiles, ok := tc.wantFilesChanged[spec.BaseRepository]
203+
if !ok {
204+
t.Fatalf("unexpected file changes in repo %s", spec.BaseRepository)
195205
}
196-
}
197206

198-
for _, file := range wantFiles {
199-
if _, ok := diffsByName[file]; !ok {
200-
t.Errorf("%s was not changed (diffsByName=%#v)", file, diffsByName)
207+
if have, want := len(fileDiffs), len(wantFiles); have != want {
208+
t.Fatalf("repo %s: wrong number of fileDiffs. want=%d, have=%d", spec.BaseRepository, want, have)
201209
}
210+
211+
diffsByName := map[string]*diff.FileDiff{}
212+
for _, fd := range fileDiffs {
213+
if fd.NewName == "/dev/null" {
214+
diffsByName[fd.OrigName] = fd
215+
} else {
216+
diffsByName[fd.NewName] = fd
217+
}
218+
}
219+
220+
for _, file := range wantFiles {
221+
if _, ok := diffsByName[file]; !ok {
222+
t.Errorf("%s was not changed (diffsByName=%#v)", file, diffsByName)
223+
}
224+
}
225+
}
226+
}
227+
228+
verifyCache := func() {
229+
want := len(tc.repos)
230+
if tc.wantErrInclude != "" {
231+
want = 0
232+
}
233+
234+
// Verify that there is a cache entry for each repo.
235+
if have := cache.size(); have != want {
236+
t.Errorf("unexpected number of cache entries: have=%d want=%d cache=%+v", have, want, cache)
202237
}
203238
}
239+
240+
// Sanity check, since we're going to be looking at the side effects
241+
// on the cache.
242+
if cache.size() != 0 {
243+
t.Fatalf("unexpectedly hot cache: %+v", cache)
244+
}
245+
246+
// Run with a cold cache.
247+
t.Run("cold cache", func(t *testing.T) {
248+
execute()
249+
verifyCache()
250+
})
251+
252+
// Run with a warm cache.
253+
t.Run("warm cache", func(t *testing.T) {
254+
execute()
255+
verifyCache()
256+
})
204257
})
205258
}
206259
}
@@ -254,3 +307,73 @@ func newZipArchivesMux(t *testing.T, callback http.HandlerFunc, archives ...mock
254307

255308
return mux
256309
}
310+
311+
// inMemoryExecutionCache provides an in-memory cache for testing purposes.
312+
type inMemoryExecutionCache struct {
313+
cache map[string][]byte
314+
mu sync.RWMutex
315+
}
316+
317+
func newInMemoryExecutionCache() *inMemoryExecutionCache {
318+
return &inMemoryExecutionCache{
319+
cache: make(map[string][]byte),
320+
}
321+
}
322+
323+
func (c *inMemoryExecutionCache) size() int {
324+
c.mu.RLock()
325+
defer c.mu.RUnlock()
326+
327+
return len(c.cache)
328+
}
329+
330+
func (c *inMemoryExecutionCache) Get(ctx context.Context, key ExecutionCacheKey) (*ChangesetSpec, error) {
331+
k, err := key.Key()
332+
if err != nil {
333+
return nil, err
334+
}
335+
336+
c.mu.RLock()
337+
defer c.mu.RUnlock()
338+
339+
if raw, ok := c.cache[k]; ok {
340+
var spec ChangesetSpec
341+
if err := json.Unmarshal(raw, &spec); err != nil {
342+
return nil, err
343+
}
344+
345+
return &spec, nil
346+
}
347+
return nil, nil
348+
}
349+
350+
func (c *inMemoryExecutionCache) Set(ctx context.Context, key ExecutionCacheKey, spec *ChangesetSpec) error {
351+
k, err := key.Key()
352+
if err != nil {
353+
return err
354+
}
355+
356+
v, err := json.Marshal(spec)
357+
if err != nil {
358+
return err
359+
}
360+
361+
c.mu.Lock()
362+
defer c.mu.Unlock()
363+
364+
c.cache[k] = v
365+
return nil
366+
}
367+
368+
func (c *inMemoryExecutionCache) Clear(ctx context.Context, key ExecutionCacheKey) error {
369+
k, err := key.Key()
370+
if err != nil {
371+
return err
372+
}
373+
374+
c.mu.Lock()
375+
defer c.mu.Unlock()
376+
377+
delete(c.cache, k)
378+
return nil
379+
}

0 commit comments

Comments
 (0)