go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/cv/internal/gerrit/poller/poller_test.go (about)

     1  // Copyright 2020 The LUCI Authors.
     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 poller
    16  
    17  import (
    18  	"context"
    19  	"fmt"
    20  	"sort"
    21  	"sync"
    22  	"testing"
    23  	"time"
    24  
    25  	"google.golang.org/protobuf/types/known/timestamppb"
    26  
    27  	"go.chromium.org/luci/common/clock"
    28  	gerritpb "go.chromium.org/luci/common/proto/gerrit"
    29  	"go.chromium.org/luci/gae/service/datastore"
    30  	"go.chromium.org/luci/server/tq/tqtesting"
    31  
    32  	cfgpb "go.chromium.org/luci/cv/api/config/v2"
    33  	"go.chromium.org/luci/cv/internal/changelist"
    34  	"go.chromium.org/luci/cv/internal/common"
    35  	"go.chromium.org/luci/cv/internal/configs/prjcfg/prjcfgtest"
    36  	"go.chromium.org/luci/cv/internal/cvtesting"
    37  	gf "go.chromium.org/luci/cv/internal/gerrit/gerritfake"
    38  
    39  	. "github.com/smartystreets/goconvey/convey"
    40  )
    41  
    42  func TestSchedule(t *testing.T) {
    43  	t.Parallel()
    44  
    45  	Convey("Schedule works", t, func() {
    46  		ct := cvtesting.Test{}
    47  		ctx, cancel := ct.SetUp(t)
    48  		defer cancel()
    49  
    50  		ct.Clock.Set(ct.Clock.Now().Truncate(pollInterval).Add(pollInterval))
    51  		const project = "chromium"
    52  
    53  		p := New(ct.TQDispatcher, nil, nil, nil)
    54  
    55  		So(p.schedule(ctx, project, time.Time{}), ShouldBeNil)
    56  		payloads := FilterPayloads(ct.TQ.Tasks().SortByETA().Payloads())
    57  		So(payloads, ShouldHaveLength, 1)
    58  		first := payloads[0]
    59  		So(first.GetLuciProject(), ShouldEqual, project)
    60  		firstETA := first.GetEta().AsTime()
    61  		So(firstETA.UnixNano(), ShouldBeBetweenOrEqual,
    62  			ct.Clock.Now().UnixNano(), ct.Clock.Now().Add(pollInterval).UnixNano())
    63  
    64  		Convey("idempotency via task deduplication", func() {
    65  			So(p.schedule(ctx, project, time.Time{}), ShouldBeNil)
    66  			So(FilterPayloads(ct.TQ.Tasks().SortByETA().Payloads()), ShouldHaveLength, 1)
    67  
    68  			Convey("but only for the same project", func() {
    69  				So(p.schedule(ctx, "another-project", time.Time{}), ShouldBeNil)
    70  				ids := FilterProjects(ct.TQ.Tasks().SortByETA().Payloads())
    71  				sort.Strings(ids)
    72  				So(ids, ShouldResemble, []string{"another-project", project})
    73  			})
    74  		})
    75  
    76  		Convey("schedule next poll", func() {
    77  			So(p.schedule(ctx, project, firstETA), ShouldBeNil)
    78  			payloads := FilterPayloads(ct.TQ.Tasks().SortByETA().Payloads())
    79  			So(payloads, ShouldHaveLength, 2)
    80  			So(payloads[1].GetEta().AsTime(), ShouldEqual, firstETA.Add(pollInterval))
    81  
    82  			Convey("from a delayed prior poll", func() {
    83  				ct.Clock.Set(firstETA.Add(pollInterval).Add(pollInterval / 2))
    84  				So(p.schedule(ctx, project, firstETA), ShouldBeNil)
    85  				payloads := FilterPayloads(ct.TQ.Tasks().SortByETA().Payloads())
    86  				So(payloads, ShouldHaveLength, 3)
    87  				So(payloads[2].GetEta().AsTime(), ShouldEqual, firstETA.Add(2*pollInterval))
    88  			})
    89  		})
    90  	})
    91  }
    92  
    93  func TestObservesProjectLifetime(t *testing.T) {
    94  	t.Parallel()
    95  
    96  	Convey("Gerrit Poller observes project lifetime", t, func() {
    97  		ct := cvtesting.Test{}
    98  		ctx, cancel := ct.SetUp(t)
    99  		defer cancel()
   100  
   101  		const lProject = "chromium"
   102  		const gHost = "chromium-review.example.com"
   103  		const gRepo = "infra/infra"
   104  
   105  		mustLoadState := func() *State {
   106  			st := &State{LuciProject: lProject}
   107  			So(datastore.Get(ctx, st), ShouldBeNil)
   108  			return st
   109  		}
   110  
   111  		p := New(ct.TQDispatcher, ct.GFactory(), &clUpdaterMock{}, &pmMock{})
   112  
   113  		Convey("Without project config, does nothing", func() {
   114  			So(p.poll(ctx, lProject, ct.Clock.Now()), ShouldBeNil)
   115  			So(ct.TQ.Tasks(), ShouldBeEmpty)
   116  			So(datastore.Get(ctx, &State{LuciProject: lProject}), ShouldEqual, datastore.ErrNoSuchEntity)
   117  		})
   118  
   119  		Convey("For an existing project, runs via a task chain", func() {
   120  			prjcfgtest.Create(ctx, lProject, singleRepoConfig(gHost, gRepo))
   121  			So(p.poll(ctx, lProject, ct.Clock.Now()), ShouldBeNil)
   122  			So(mustLoadState().EVersion, ShouldEqual, 1)
   123  			for i := 0; i < 10; i++ {
   124  				So(ct.TQ.Tasks(), ShouldHaveLength, 1)
   125  				ct.TQ.Run(ctx, tqtesting.StopAfterTask(taskClassID))
   126  			}
   127  			So(mustLoadState().EVersion, ShouldEqual, 11)
   128  		})
   129  
   130  		Convey("On config changes, updates its state", func() {
   131  			prjcfgtest.Create(ctx, lProject, singleRepoConfig(gHost, gRepo))
   132  			So(p.poll(ctx, lProject, ct.Clock.Now()), ShouldBeNil)
   133  			s := mustLoadState()
   134  			So(s.QueryStates.GetStates(), ShouldHaveLength, 1)
   135  			qs0 := s.QueryStates.GetStates()[0]
   136  			So(qs0.GetHost(), ShouldResemble, gHost)
   137  			So(qs0.GetOrProjects(), ShouldResemble, []string{gRepo})
   138  
   139  			const gRepo2 = "infra/zzzzz"
   140  			prjcfgtest.Update(ctx, lProject, singleRepoConfig(gHost, gRepo, gRepo2))
   141  			So(p.poll(ctx, lProject, ct.Clock.Now()), ShouldBeNil)
   142  			s = mustLoadState()
   143  			So(s.QueryStates.GetStates(), ShouldHaveLength, 1)
   144  			qs0 = s.QueryStates.GetStates()[0]
   145  			So(qs0.GetOrProjects(), ShouldResemble, []string{gRepo, gRepo2})
   146  
   147  			prjcfgtest.Update(ctx, lProject, singleRepoConfig(gHost, gRepo2))
   148  			So(p.poll(ctx, lProject, ct.Clock.Now()), ShouldBeNil)
   149  			s = mustLoadState()
   150  			So(s.QueryStates.GetStates(), ShouldHaveLength, 1)
   151  			qs0 = s.QueryStates.GetStates()[0]
   152  			So(qs0.GetOrProjects(), ShouldResemble, []string{gRepo2})
   153  		})
   154  
   155  		Convey("Once project is disabled, deletes state and task chain stops running", func() {
   156  			prjcfgtest.Create(ctx, lProject, singleRepoConfig(gHost, gRepo))
   157  			So(p.poll(ctx, lProject, ct.Clock.Now()), ShouldBeNil)
   158  			So(ct.TQ.Tasks(), ShouldHaveLength, 1)
   159  			So(mustLoadState().EVersion, ShouldEqual, 1)
   160  
   161  			prjcfgtest.Disable(ctx, lProject)
   162  			ct.TQ.Run(ctx, tqtesting.StopAfterTask(taskClassID))
   163  
   164  			So(datastore.Get(ctx, &State{LuciProject: lProject}), ShouldEqual, datastore.ErrNoSuchEntity)
   165  			So(ct.TQ.Tasks(), ShouldBeEmpty)
   166  		})
   167  	})
   168  }
   169  
   170  func TestDiscoversCLs(t *testing.T) {
   171  	t.Parallel()
   172  
   173  	Convey("Gerrit Poller discovers CLs", t, func() {
   174  		ct := cvtesting.Test{}
   175  		ctx, cancel := ct.SetUp(t)
   176  		defer cancel()
   177  
   178  		const lProject = "chromium"
   179  		const gHost = "chromium-review.example.com"
   180  		const gRepo = "infra/infra"
   181  
   182  		mustLoadState := func() *State {
   183  			st := &State{LuciProject: lProject}
   184  			So(datastore.Get(ctx, st), ShouldBeNil)
   185  			return st
   186  		}
   187  		ensureCLEntity := func(change int64) *changelist.CL {
   188  			return changelist.MustGobID(gHost, change).MustCreateIfNotExists(ctx)
   189  		}
   190  
   191  		pm := pmMock{}
   192  		clUpdater := clUpdaterMock{}
   193  		p := New(ct.TQDispatcher, ct.GFactory(), &clUpdater, &pm)
   194  
   195  		prjcfgtest.Create(ctx, lProject, singleRepoConfig(gHost, gRepo))
   196  		// Initialize Poller state for ease of modifications in test later.
   197  		So(p.poll(ctx, lProject, ct.Clock.Now()), ShouldBeNil)
   198  		ct.Clock.Add(10 * fullPollInterval)
   199  
   200  		ct.GFake.AddFrom(gf.WithCIs(gHost, gf.ACLPublic(),
   201  			// These CLs ordered from oldest to newest by .Updated.
   202  			gf.CI(31, gf.CQ(+2), gf.Project(gRepo), gf.Updated(ct.Clock.Now().Add(-2*fullPollInterval))),
   203  			gf.CI(32, gf.CQ(+1), gf.Project(gRepo), gf.Updated(ct.Clock.Now().Add(-1*fullPollInterval))),
   204  			gf.CI(33, gf.CQ(+2), gf.Project(gRepo), gf.Updated(ct.Clock.Now().Add(-2*incrementalPollOverlap))),
   205  			gf.CI(34, gf.CQ(+1), gf.Project(gRepo), gf.Updated(ct.Clock.Now().Add(-1*incrementalPollOverlap))),
   206  			gf.CI(35, gf.CQ(+1), gf.Project(gRepo), gf.Updated(ct.Clock.Now().Add(-1*time.Millisecond))),
   207  			// No CQ vote. This will not show up on a full query, but only on an incremental.
   208  			gf.CI(36, gf.Project(gRepo), gf.Updated(ct.Clock.Now().Add(-time.Minute))),
   209  
   210  			// These must not be matched.
   211  			gf.CI(40, gf.CQ(+2), gf.Project(gRepo), gf.Updated(ct.Clock.Now().Add(-common.MaxTriggerAge-time.Second))),
   212  			gf.CI(41, gf.CQ(+2), gf.Project("another/project"), gf.Updated(ct.Clock.Now())),
   213  			// TODO(tandrii): when poller becomes ref-ware, add this to the test.
   214  			// gf.CI(42, gf.CQ(+2), gf.Project(gRepo), gf.Ref("refs/not/matched"), gf.Updated(ct.Clock.Now())),
   215  		))
   216  
   217  		Convey("Discover all CLs in case of a full query", func() {
   218  			s := mustLoadState()
   219  			s.QueryStates.GetStates()[0].LastFullTime = nil // Force "full" fetch
   220  			So(datastore.Put(ctx, s), ShouldBeNil)
   221  
   222  			postFullQueryVerify := func() {
   223  				qs := mustLoadState().QueryStates.GetStates()[0]
   224  				So(qs.GetLastFullTime().AsTime(), ShouldResemble, ct.Clock.Now().UTC())
   225  				So(qs.GetLastIncrTime(), ShouldBeNil)
   226  				So(qs.GetChanges(), ShouldResemble, []int64{31, 32, 33, 34, 35})
   227  			}
   228  
   229  			Convey("On project start, just creates CLUpdater tasks with forceNotify", func() {
   230  				So(p.poll(ctx, lProject, ct.Clock.Now()), ShouldBeNil)
   231  				So(clUpdater.peekScheduledChanges(), ShouldResemble, []int{31, 32, 33, 34, 35})
   232  				postFullQueryVerify()
   233  			})
   234  
   235  			Convey("In a typical case, uses forceNotify judiciously", func() {
   236  				// In a typical case, CV has been polling before and so is already aware
   237  				// of every CL except 35.
   238  				s.QueryStates.GetStates()[0].Changes = []int64{31, 32, 33, 34}
   239  				So(datastore.Put(ctx, s), ShouldBeNil)
   240  				// However, 34 may not yet have an CL entity.
   241  				knownCLIDs := common.CLIDs{
   242  					ensureCLEntity(31).ID,
   243  					ensureCLEntity(32).ID,
   244  					ensureCLEntity(33).ID,
   245  				}
   246  
   247  				So(p.poll(ctx, lProject, ct.Clock.Now()), ShouldBeNil)
   248  
   249  				// PM must be notified in "bulk".
   250  				So(pm.popNotifiedCLs(lProject), ShouldResemble, sortedCLIDs(knownCLIDs...))
   251  				// All CLs must have clUpdater tasks.
   252  				So(clUpdater.peekScheduledChanges(), ShouldResemble, []int{31, 32, 33, 34, 35})
   253  				postFullQueryVerify()
   254  			})
   255  
   256  			Convey("When previously known changes are no longer found, forces their refresh, too", func() {
   257  				// Test common occurrence of CL no longer appearing in query
   258  				// results due to user or even CV action.
   259  				ct.GFake.AddFrom(gf.WithCIs(gHost, gf.ACLPublic(),
   260  					// No CQ vote.
   261  					gf.CI(25, gf.Project(gRepo), gf.Updated(ct.Clock.Now().Add(-time.Minute))),
   262  					// Abandoned.
   263  					gf.CI(26, gf.Status(gerritpb.ChangeStatus_ABANDONED), gf.CQ(+2), gf.Project(gRepo), gf.Updated(ct.Clock.Now().Add(-time.Minute))),
   264  					// Submitted.
   265  					gf.CI(27, gf.Status(gerritpb.ChangeStatus_ABANDONED), gf.CQ(+2), gf.Project(gRepo), gf.Updated(ct.Clock.Now().Add(-time.Minute))),
   266  				))
   267  				s.QueryStates.GetStates()[0].Changes = []int64{25, 26, 27, 31, 32, 33, 34}
   268  				So(datastore.Put(ctx, s), ShouldBeNil)
   269  				var knownCLIDs common.CLIDs
   270  				for _, c := range s.QueryStates.GetStates()[0].Changes {
   271  					knownCLIDs = append(knownCLIDs, ensureCLEntity(c).ID)
   272  				}
   273  
   274  				So(p.poll(ctx, lProject, ct.Clock.Now()), ShouldBeNil)
   275  
   276  				// PM must be notified in "bulk" for all previously known CLs.
   277  				So(pm.popNotifiedCLs(lProject), ShouldResemble, sortedCLIDs(knownCLIDs...))
   278  				// All current and prior CLs must have clUpdater tasks.
   279  				So(clUpdater.peekScheduledChanges(), ShouldResemble, []int{25, 26, 27, 31, 32, 33, 34, 35})
   280  				postFullQueryVerify()
   281  			})
   282  
   283  			Convey("On config change, force notifies PM and runs a full query", func() {
   284  				// Strictly speaking, this test isn't just a full poll, but also a
   285  				// config change.
   286  
   287  				// Simulate prior full fetch has just happened.
   288  				s.QueryStates.GetStates()[0].LastFullTime = timestamppb.New(ct.Clock.Now().Add(-pollInterval))
   289  				// But with a different query.
   290  				s.QueryStates.GetStates()[0].OrProjects = []string{gRepo, "repo/which/had/cl30"}
   291  				// And all CLs except but 35 are already known but also CL 30.
   292  				s.QueryStates.GetStates()[0].Changes = []int64{30, 31, 32, 33, 34}
   293  				s.ConfigHash = "some/other/hash"
   294  				So(datastore.Put(ctx, s), ShouldBeNil)
   295  				var knownCLIDs common.CLIDs
   296  				for _, c := range s.QueryStates.GetStates()[0].Changes {
   297  					knownCLIDs = append(knownCLIDs, ensureCLEntity(c).ID)
   298  				}
   299  
   300  				So(p.poll(ctx, lProject, ct.Clock.Now()), ShouldBeNil)
   301  
   302  				// PM must be notified about all prior CLs.
   303  				So(pm.popNotifiedCLs(lProject), ShouldResemble, sortedCLIDs(knownCLIDs...))
   304  				// All CLs must have clUpdater tasks.
   305  				// NOTE: the code isn't optimized for this use case, so there will be
   306  				// multiple tasks for changes 31..34. While this is unfortunte, it's
   307  				// rare enough that it doesn't really matter.
   308  				So(clUpdater.peekScheduledChanges(), ShouldResemble, []int{30, 31, 31, 32, 32, 33, 33, 34, 34, 35})
   309  				postFullQueryVerify()
   310  
   311  				qs := mustLoadState().QueryStates.GetStates()[0]
   312  				So(qs.GetOrProjects(), ShouldResemble, []string{gRepo})
   313  			})
   314  		})
   315  
   316  		Convey("Discover most recently modified CLs only in case of an incremental query", func() {
   317  			s := mustLoadState()
   318  			s.QueryStates.GetStates()[0].LastFullTime = timestamppb.New(ct.Clock.Now()) // Force incremental fetch
   319  			So(datastore.Put(ctx, s), ShouldBeNil)
   320  
   321  			Convey("Unless the pubsub is enabled", func() {
   322  				So(p.poll(ctx, lProject, ct.Clock.Now()), ShouldBeNil)
   323  				So(clUpdater.peekScheduledChanges(), ShouldBeEmpty)
   324  				qs := mustLoadState().QueryStates.GetStates()[0]
   325  				So(qs.GetLastIncrTime(), ShouldBeNil)
   326  			})
   327  
   328  			ct.DisableProjectInGerritListener(ctx, lProject)
   329  
   330  			Convey("In a typical case, schedules update tasks for new CLs", func() {
   331  				s.QueryStates.GetStates()[0].Changes = []int64{31, 32, 33}
   332  				So(datastore.Put(ctx, s), ShouldBeNil)
   333  				So(p.poll(ctx, lProject, ct.Clock.Now()), ShouldBeNil)
   334  
   335  				So(clUpdater.peekScheduledChanges(), ShouldResemble, []int{34, 35, 36})
   336  
   337  				qs := mustLoadState().QueryStates.GetStates()[0]
   338  				So(qs.GetLastIncrTime().AsTime(), ShouldResemble, ct.Clock.Now().UTC())
   339  				So(qs.GetChanges(), ShouldResemble, []int64{31, 32, 33, 34, 35, 36})
   340  			})
   341  
   342  			Convey("Even if CL is already known, schedules update tasks", func() {
   343  				s.QueryStates.GetStates()[0].Changes = []int64{31, 32, 33, 34, 35, 36}
   344  				So(datastore.Put(ctx, s), ShouldBeNil)
   345  				So(p.poll(ctx, lProject, ct.Clock.Now()), ShouldBeNil)
   346  
   347  				qs := mustLoadState().QueryStates.GetStates()[0]
   348  				So(qs.GetLastIncrTime().AsTime(), ShouldResemble, ct.Clock.Now().UTC())
   349  				So(qs.GetChanges(), ShouldResemble, []int64{31, 32, 33, 34, 35, 36})
   350  			})
   351  
   352  		})
   353  	})
   354  }
   355  
   356  func singleRepoConfig(gHost string, gRepos ...string) *cfgpb.Config {
   357  	projects := make([]*cfgpb.ConfigGroup_Gerrit_Project, len(gRepos))
   358  	for i, gRepo := range gRepos {
   359  		projects[i] = &cfgpb.ConfigGroup_Gerrit_Project{
   360  			Name:      gRepo,
   361  			RefRegexp: []string{"refs/heads/main"},
   362  		}
   363  	}
   364  	return &cfgpb.Config{
   365  		ConfigGroups: []*cfgpb.ConfigGroup{
   366  			{
   367  				Name: "main",
   368  				Gerrit: []*cfgpb.ConfigGroup_Gerrit{
   369  					{
   370  						Url:      "https://" + gHost + "/",
   371  						Projects: projects,
   372  					},
   373  				},
   374  			},
   375  		},
   376  	}
   377  }
   378  
   379  func sharedPrefixRepos(prefix string, n int) []string {
   380  	rs := make([]string, n)
   381  	for i := range rs {
   382  		rs[i] = fmt.Sprintf("%s/%03d", prefix, i)
   383  	}
   384  	return rs
   385  }
   386  
   387  type pmMock struct {
   388  	projects map[string]common.CLIDs
   389  }
   390  
   391  func (p *pmMock) NotifyCLsUpdated(ctx context.Context, project string, cls *changelist.CLUpdatedEvents) error {
   392  	if p.projects == nil {
   393  		p.projects = make(map[string]common.CLIDs, len(cls.GetEvents()))
   394  	}
   395  	for _, e := range cls.GetEvents() {
   396  		p.projects[project] = append(p.projects[project], common.CLID(e.GetClid()))
   397  	}
   398  	return nil
   399  }
   400  
   401  func (p *pmMock) popNotifiedCLs(luciProject string) common.CLIDs {
   402  	if p.projects == nil {
   403  		return nil
   404  	}
   405  	res := p.projects[luciProject]
   406  	delete(p.projects, luciProject)
   407  	return sortedCLIDs(res...)
   408  }
   409  
   410  func sortedCLIDs(ids ...common.CLID) common.CLIDs {
   411  	res := common.CLIDs(ids)
   412  	res.Dedupe() // it also sorts as a by-product.
   413  	return res
   414  }
   415  
   416  type clUpdaterMock struct {
   417  	m     sync.Mutex
   418  	tasks []struct {
   419  		payload *changelist.UpdateCLTask
   420  		eta     time.Time
   421  	}
   422  }
   423  
   424  func (c *clUpdaterMock) Schedule(ctx context.Context, t *changelist.UpdateCLTask) error {
   425  	return c.ScheduleDelayed(ctx, t, 0)
   426  }
   427  
   428  func (c *clUpdaterMock) ScheduleDelayed(ctx context.Context, t *changelist.UpdateCLTask, d time.Duration) error {
   429  	c.m.Lock()
   430  	defer c.m.Unlock()
   431  	c.tasks = append(c.tasks, struct {
   432  		payload *changelist.UpdateCLTask
   433  		eta     time.Time
   434  	}{t, clock.Now(ctx).Add(d)})
   435  	return nil
   436  }
   437  
   438  func (c *clUpdaterMock) sortTasksByETAlocked() {
   439  	sort.Slice(c.tasks, func(i, j int) bool { return c.tasks[i].eta.Before(c.tasks[j].eta) })
   440  }
   441  
   442  func (c *clUpdaterMock) peekETAs() []time.Time {
   443  	c.m.Lock()
   444  	defer c.m.Unlock()
   445  	c.sortTasksByETAlocked()
   446  	out := make([]time.Time, len(c.tasks))
   447  	for i, t := range c.tasks {
   448  		out[i] = t.eta
   449  	}
   450  	return out
   451  }
   452  
   453  func (c *clUpdaterMock) popPayloadsByETA() []*changelist.UpdateCLTask {
   454  	c.m.Lock()
   455  	c.sortTasksByETAlocked()
   456  	tasks := c.tasks
   457  	c.tasks = nil
   458  	c.m.Unlock()
   459  
   460  	out := make([]*changelist.UpdateCLTask, len(tasks))
   461  	for i, t := range tasks {
   462  		out[i] = t.payload
   463  	}
   464  	return out
   465  }
   466  
   467  func (c *clUpdaterMock) peekScheduledChanges() []int {
   468  	c.m.Lock()
   469  	defer c.m.Unlock()
   470  	out := make([]int, len(c.tasks))
   471  	for i, t := range c.tasks {
   472  		_, change, err := changelist.ExternalID(t.payload.GetExternalId()).ParseGobID()
   473  		if err != nil {
   474  			panic(err)
   475  		}
   476  		out[i] = int(change)
   477  	}
   478  	sort.Ints(out)
   479  	return out
   480  }