Merge branch '13407-volume-replication'
[arvados.git] / services / keep-balance / balance_test.go
1 // Copyright (C) The Arvados Authors. All rights reserved.
2 //
3 // SPDX-License-Identifier: AGPL-3.0
4
5 package main
6
7 import (
8         "crypto/md5"
9         "fmt"
10         "sort"
11         "strconv"
12         "testing"
13         "time"
14
15         "git.curoverse.com/arvados.git/sdk/go/arvados"
16
17         check "gopkg.in/check.v1"
18 )
19
20 // Test with Gocheck
21 func Test(t *testing.T) {
22         check.TestingT(t)
23 }
24
25 var _ = check.Suite(&balancerSuite{})
26
27 type balancerSuite struct {
28         Balancer
29         srvs            []*KeepService
30         blks            map[string]tester
31         knownRendezvous [][]int
32         signatureTTL    int64
33 }
34
35 const (
36         // index into knownRendezvous
37         known0 = 0
38 )
39
40 type slots []int
41
42 type tester struct {
43         known       int
44         desired     map[string]int
45         current     slots
46         timestamps  []int64
47         shouldPull  slots
48         shouldTrash slots
49
50         shouldPullMounts  []string
51         shouldTrashMounts []string
52
53         expectResult balanceResult
54 }
55
56 func (bal *balancerSuite) SetUpSuite(c *check.C) {
57         bal.knownRendezvous = nil
58         for _, str := range []string{
59                 "3eab2d5fc9681074",
60                 "097dba52e648f1c3",
61                 "c5b4e023f8a7d691",
62                 "9d81c02e76a3bf54",
63         } {
64                 var slots []int
65                 for _, c := range []byte(str) {
66                         pos, _ := strconv.ParseUint(string(c), 16, 4)
67                         slots = append(slots, int(pos))
68                 }
69                 bal.knownRendezvous = append(bal.knownRendezvous, slots)
70         }
71
72         bal.signatureTTL = 3600
73 }
74
75 func (bal *balancerSuite) SetUpTest(c *check.C) {
76         bal.srvs = make([]*KeepService, 16)
77         bal.KeepServices = make(map[string]*KeepService)
78         for i := range bal.srvs {
79                 srv := &KeepService{
80                         KeepService: arvados.KeepService{
81                                 UUID: fmt.Sprintf("zzzzz-bi6l4-%015x", i),
82                         },
83                 }
84                 srv.mounts = []*KeepMount{{
85                         KeepMount: arvados.KeepMount{
86                                 UUID: fmt.Sprintf("zzzzz-mount-%015x", i),
87                         },
88                         KeepService: srv,
89                 }}
90                 bal.srvs[i] = srv
91                 bal.KeepServices[srv.UUID] = srv
92         }
93
94         bal.MinMtime = time.Now().UnixNano() - bal.signatureTTL*1e9
95         bal.cleanupMounts()
96 }
97
98 func (bal *balancerSuite) TestPerfect(c *check.C) {
99         bal.try(c, tester{
100                 desired:     map[string]int{"default": 2},
101                 current:     slots{0, 1},
102                 shouldPull:  nil,
103                 shouldTrash: nil})
104 }
105
106 func (bal *balancerSuite) TestDecreaseRepl(c *check.C) {
107         bal.try(c, tester{
108                 desired:     map[string]int{"default": 2},
109                 current:     slots{0, 2, 1},
110                 shouldTrash: slots{2}})
111 }
112
113 func (bal *balancerSuite) TestDecreaseReplToZero(c *check.C) {
114         bal.try(c, tester{
115                 desired:     map[string]int{"default": 0},
116                 current:     slots{0, 1, 3},
117                 shouldTrash: slots{0, 1, 3}})
118 }
119
120 func (bal *balancerSuite) TestIncreaseRepl(c *check.C) {
121         bal.try(c, tester{
122                 desired:    map[string]int{"default": 4},
123                 current:    slots{0, 1},
124                 shouldPull: slots{2, 3}})
125 }
126
127 func (bal *balancerSuite) TestSkipReadonly(c *check.C) {
128         bal.srvList(0, slots{3})[0].ReadOnly = true
129         bal.try(c, tester{
130                 desired:    map[string]int{"default": 4},
131                 current:    slots{0, 1},
132                 shouldPull: slots{2, 4}})
133 }
134
135 func (bal *balancerSuite) TestFixUnbalanced(c *check.C) {
136         bal.try(c, tester{
137                 desired:    map[string]int{"default": 2},
138                 current:    slots{2, 0},
139                 shouldPull: slots{1}})
140         bal.try(c, tester{
141                 desired:    map[string]int{"default": 2},
142                 current:    slots{2, 7},
143                 shouldPull: slots{0, 1}})
144         // if only one of the pulls succeeds, we'll see this next:
145         bal.try(c, tester{
146                 desired:     map[string]int{"default": 2},
147                 current:     slots{2, 1, 7},
148                 shouldPull:  slots{0},
149                 shouldTrash: slots{7}})
150         // if both pulls succeed, we'll see this next:
151         bal.try(c, tester{
152                 desired:     map[string]int{"default": 2},
153                 current:     slots{2, 0, 1, 7},
154                 shouldTrash: slots{2, 7}})
155
156         // unbalanced + excessive replication => pull + trash
157         bal.try(c, tester{
158                 desired:     map[string]int{"default": 2},
159                 current:     slots{2, 5, 7},
160                 shouldPull:  slots{0, 1},
161                 shouldTrash: slots{7}})
162 }
163
164 func (bal *balancerSuite) TestMultipleReplicasPerService(c *check.C) {
165         for _, srv := range bal.srvs {
166                 for i := 0; i < 3; i++ {
167                         m := *(srv.mounts[0])
168                         srv.mounts = append(srv.mounts, &m)
169                 }
170         }
171         bal.try(c, tester{
172                 desired:    map[string]int{"default": 2},
173                 current:    slots{0, 0},
174                 shouldPull: slots{1}})
175         bal.try(c, tester{
176                 desired:    map[string]int{"default": 2},
177                 current:    slots{2, 2},
178                 shouldPull: slots{0, 1}})
179         bal.try(c, tester{
180                 desired:     map[string]int{"default": 2},
181                 current:     slots{0, 0, 1},
182                 shouldTrash: slots{0}})
183         bal.try(c, tester{
184                 desired:     map[string]int{"default": 2},
185                 current:     slots{1, 1, 0},
186                 shouldTrash: slots{1}})
187         bal.try(c, tester{
188                 desired:     map[string]int{"default": 2},
189                 current:     slots{1, 0, 1, 0, 2},
190                 shouldTrash: slots{0, 1, 2}})
191         bal.try(c, tester{
192                 desired:     map[string]int{"default": 2},
193                 current:     slots{1, 1, 1, 0, 2},
194                 shouldTrash: slots{1, 1, 2}})
195         bal.try(c, tester{
196                 desired:     map[string]int{"default": 2},
197                 current:     slots{1, 1, 2},
198                 shouldPull:  slots{0},
199                 shouldTrash: slots{1}})
200         bal.try(c, tester{
201                 desired:     map[string]int{"default": 2},
202                 current:     slots{1, 1, 0},
203                 timestamps:  []int64{12345678, 12345678, 12345679},
204                 shouldTrash: nil})
205         bal.try(c, tester{
206                 desired:    map[string]int{"default": 2},
207                 current:    slots{1, 1},
208                 shouldPull: slots{0}})
209 }
210
211 func (bal *balancerSuite) TestIncreaseReplTimestampCollision(c *check.C) {
212         // For purposes of increasing replication, we assume identical
213         // replicas are distinct.
214         bal.try(c, tester{
215                 desired:    map[string]int{"default": 4},
216                 current:    slots{0, 1},
217                 timestamps: []int64{12345678, 12345678},
218                 shouldPull: slots{2, 3}})
219 }
220
221 func (bal *balancerSuite) TestDecreaseReplTimestampCollision(c *check.C) {
222         // For purposes of decreasing replication, we assume identical
223         // replicas are NOT distinct.
224         bal.try(c, tester{
225                 desired:    map[string]int{"default": 2},
226                 current:    slots{0, 1, 2},
227                 timestamps: []int64{12345678, 12345678, 12345678}})
228         bal.try(c, tester{
229                 desired:    map[string]int{"default": 2},
230                 current:    slots{0, 1, 2},
231                 timestamps: []int64{12345678, 10000000, 10000000}})
232 }
233
234 func (bal *balancerSuite) TestDecreaseReplBlockTooNew(c *check.C) {
235         oldTime := bal.MinMtime - 3600
236         newTime := bal.MinMtime + 3600
237         // The excess replica is too new to delete.
238         bal.try(c, tester{
239                 desired:    map[string]int{"default": 2},
240                 current:    slots{0, 1, 2},
241                 timestamps: []int64{oldTime, newTime, newTime + 1}})
242         // The best replicas are too new to delete, but the excess
243         // replica is old enough.
244         bal.try(c, tester{
245                 desired:     map[string]int{"default": 2},
246                 current:     slots{0, 1, 2},
247                 timestamps:  []int64{newTime, newTime + 1, oldTime},
248                 shouldTrash: slots{2}})
249 }
250
251 func (bal *balancerSuite) TestCleanupMounts(c *check.C) {
252         bal.srvs[3].mounts[0].KeepMount.ReadOnly = true
253         bal.srvs[3].mounts[0].KeepMount.DeviceID = "abcdef"
254         bal.srvs[14].mounts[0].KeepMount.DeviceID = "abcdef"
255         c.Check(len(bal.srvs[3].mounts), check.Equals, 1)
256         bal.cleanupMounts()
257         c.Check(len(bal.srvs[3].mounts), check.Equals, 0)
258         bal.try(c, tester{
259                 known:      0,
260                 desired:    map[string]int{"default": 2},
261                 current:    slots{1},
262                 shouldPull: slots{2}})
263 }
264
265 func (bal *balancerSuite) TestVolumeReplication(c *check.C) {
266         bal.srvs[0].mounts[0].KeepMount.Replication = 2  // srv 0
267         bal.srvs[14].mounts[0].KeepMount.Replication = 2 // srv e
268         bal.cleanupMounts()
269         // block 0 rendezvous is 3,e,a -- so slot 1 has repl=2
270         bal.try(c, tester{
271                 known:      0,
272                 desired:    map[string]int{"default": 2},
273                 current:    slots{1},
274                 shouldPull: slots{0}})
275         bal.try(c, tester{
276                 known:      0,
277                 desired:    map[string]int{"default": 2},
278                 current:    slots{0, 1},
279                 shouldPull: nil})
280         bal.try(c, tester{
281                 known:       0,
282                 desired:     map[string]int{"default": 2},
283                 current:     slots{0, 1, 2},
284                 shouldTrash: slots{2}})
285         bal.try(c, tester{
286                 known:       0,
287                 desired:     map[string]int{"default": 3},
288                 current:     slots{0, 2, 3, 4},
289                 shouldPull:  slots{1},
290                 shouldTrash: slots{4},
291                 expectResult: balanceResult{
292                         have: 4,
293                         want: 3,
294                         classState: map[string]balancedBlockState{"default": {
295                                 desired:      3,
296                                 surplus:      1,
297                                 unachievable: false}}}})
298         bal.try(c, tester{
299                 known:       0,
300                 desired:     map[string]int{"default": 3},
301                 current:     slots{0, 1, 2, 3, 4},
302                 shouldTrash: slots{2, 3, 4}})
303         bal.try(c, tester{
304                 known:       0,
305                 desired:     map[string]int{"default": 4},
306                 current:     slots{0, 1, 2, 3, 4},
307                 shouldTrash: slots{3, 4},
308                 expectResult: balanceResult{
309                         have: 6,
310                         want: 4,
311                         classState: map[string]balancedBlockState{"default": {
312                                 desired:      4,
313                                 surplus:      2,
314                                 unachievable: false}}}})
315         // block 1 rendezvous is 0,9,7 -- so slot 0 has repl=2
316         bal.try(c, tester{
317                 known:   1,
318                 desired: map[string]int{"default": 2},
319                 current: slots{0},
320                 expectResult: balanceResult{
321                         have: 2,
322                         want: 2,
323                         classState: map[string]balancedBlockState{"default": {
324                                 desired:      2,
325                                 surplus:      0,
326                                 unachievable: false}}}})
327         bal.try(c, tester{
328                 known:      1,
329                 desired:    map[string]int{"default": 3},
330                 current:    slots{0},
331                 shouldPull: slots{1}})
332         bal.try(c, tester{
333                 known:      1,
334                 desired:    map[string]int{"default": 4},
335                 current:    slots{0},
336                 shouldPull: slots{1, 2}})
337         bal.try(c, tester{
338                 known:      1,
339                 desired:    map[string]int{"default": 4},
340                 current:    slots{2},
341                 shouldPull: slots{0, 1}})
342         bal.try(c, tester{
343                 known:      1,
344                 desired:    map[string]int{"default": 4},
345                 current:    slots{7},
346                 shouldPull: slots{0, 1, 2},
347                 expectResult: balanceResult{
348                         have: 1,
349                         want: 4,
350                         classState: map[string]balancedBlockState{"default": {
351                                 desired:      4,
352                                 surplus:      -3,
353                                 unachievable: false}}}})
354         bal.try(c, tester{
355                 known:       1,
356                 desired:     map[string]int{"default": 2},
357                 current:     slots{1, 2, 3, 4},
358                 shouldPull:  slots{0},
359                 shouldTrash: slots{3, 4}})
360         bal.try(c, tester{
361                 known:       1,
362                 desired:     map[string]int{"default": 2},
363                 current:     slots{0, 1, 2},
364                 shouldTrash: slots{1, 2},
365                 expectResult: balanceResult{
366                         have: 4,
367                         want: 2,
368                         classState: map[string]balancedBlockState{"default": {
369                                 desired:      2,
370                                 surplus:      2,
371                                 unachievable: false}}}})
372 }
373
374 func (bal *balancerSuite) TestDeviceRWMountedByMultipleServers(c *check.C) {
375         bal.srvs[0].mounts[0].KeepMount.DeviceID = "abcdef"
376         bal.srvs[9].mounts[0].KeepMount.DeviceID = "abcdef"
377         bal.srvs[14].mounts[0].KeepMount.DeviceID = "abcdef"
378         // block 0 belongs on servers 3 and e, which have different
379         // device IDs.
380         bal.try(c, tester{
381                 known:      0,
382                 desired:    map[string]int{"default": 2},
383                 current:    slots{1},
384                 shouldPull: slots{0}})
385         // block 1 belongs on servers 0 and 9, which both report
386         // having a replica, but the replicas are on the same device
387         // ID -- so we should pull to the third position (7).
388         bal.try(c, tester{
389                 known:      1,
390                 desired:    map[string]int{"default": 2},
391                 current:    slots{0, 1},
392                 shouldPull: slots{2}})
393         // block 1 can be pulled to the doubly-mounted device, but the
394         // pull should only be done on the first of the two servers.
395         bal.try(c, tester{
396                 known:      1,
397                 desired:    map[string]int{"default": 2},
398                 current:    slots{2},
399                 shouldPull: slots{0}})
400         // block 0 has one replica on a single device mounted on two
401         // servers (e,9 at positions 1,9). Trashing the replica on 9
402         // would lose the block.
403         bal.try(c, tester{
404                 known:      0,
405                 desired:    map[string]int{"default": 2},
406                 current:    slots{1, 9},
407                 shouldPull: slots{0},
408                 expectResult: balanceResult{
409                         have: 1,
410                         classState: map[string]balancedBlockState{"default": {
411                                 desired:      2,
412                                 surplus:      -1,
413                                 unachievable: false}}}})
414         // block 0 is overreplicated, but the second and third
415         // replicas are the same replica according to DeviceID
416         // (despite different Mtimes). Don't trash the third replica.
417         bal.try(c, tester{
418                 known:   0,
419                 desired: map[string]int{"default": 2},
420                 current: slots{0, 1, 9},
421                 expectResult: balanceResult{
422                         have: 2,
423                         classState: map[string]balancedBlockState{"default": {
424                                 desired:      2,
425                                 surplus:      0,
426                                 unachievable: false}}}})
427         // block 0 is overreplicated; the third and fifth replicas are
428         // extra, but the fourth is another view of the second and
429         // shouldn't be trashed.
430         bal.try(c, tester{
431                 known:       0,
432                 desired:     map[string]int{"default": 2},
433                 current:     slots{0, 1, 5, 9, 12},
434                 shouldTrash: slots{5, 12},
435                 expectResult: balanceResult{
436                         have: 4,
437                         classState: map[string]balancedBlockState{"default": {
438                                 desired:      2,
439                                 surplus:      2,
440                                 unachievable: false}}}})
441 }
442
443 func (bal *balancerSuite) TestChangeStorageClasses(c *check.C) {
444         // For known blocks 0/1/2/3, server 9 is slot 9/1/14/0 in
445         // probe order. For these tests we give it two mounts, one
446         // with classes=[special], one with
447         // classes=[special,special2].
448         bal.srvs[9].mounts = []*KeepMount{{
449                 KeepMount: arvados.KeepMount{
450                         Replication:    1,
451                         StorageClasses: []string{"special"},
452                         UUID:           "zzzzz-mount-special00000009",
453                         DeviceID:       "9-special",
454                 },
455                 KeepService: bal.srvs[9],
456         }, {
457                 KeepMount: arvados.KeepMount{
458                         Replication:    1,
459                         StorageClasses: []string{"special", "special2"},
460                         UUID:           "zzzzz-mount-special20000009",
461                         DeviceID:       "9-special-and-special2",
462                 },
463                 KeepService: bal.srvs[9],
464         }}
465         // For known blocks 0/1/2/3, server 13 (d) is slot 5/3/11/1 in
466         // probe order. We give it two mounts, one with
467         // classes=[special3], one with classes=[default].
468         bal.srvs[13].mounts = []*KeepMount{{
469                 KeepMount: arvados.KeepMount{
470                         Replication:    1,
471                         StorageClasses: []string{"special2"},
472                         UUID:           "zzzzz-mount-special2000000d",
473                         DeviceID:       "13-special2",
474                 },
475                 KeepService: bal.srvs[13],
476         }, {
477                 KeepMount: arvados.KeepMount{
478                         Replication:    1,
479                         StorageClasses: []string{"default"},
480                         UUID:           "zzzzz-mount-00000000000000d",
481                         DeviceID:       "13-default",
482                 },
483                 KeepService: bal.srvs[13],
484         }}
485         // Pull to slot 9 because that's the only server with the
486         // desired class "special".
487         bal.try(c, tester{
488                 known:            0,
489                 desired:          map[string]int{"default": 2, "special": 1},
490                 current:          slots{0, 1},
491                 shouldPull:       slots{9},
492                 shouldPullMounts: []string{"zzzzz-mount-special00000009"}})
493         // If some storage classes are not satisfied, don't trash any
494         // excess replicas. (E.g., if someone desires repl=1 on
495         // class=durable, and we have two copies on class=volatile, we
496         // should wait for pull to succeed before trashing anything).
497         bal.try(c, tester{
498                 known:            0,
499                 desired:          map[string]int{"special": 1},
500                 current:          slots{0, 1},
501                 shouldPull:       slots{9},
502                 shouldPullMounts: []string{"zzzzz-mount-special00000009"}})
503         // Once storage classes are satisfied, trash excess replicas
504         // that appear earlier in probe order but aren't needed to
505         // satisfy the desired classes.
506         bal.try(c, tester{
507                 known:       0,
508                 desired:     map[string]int{"special": 1},
509                 current:     slots{0, 1, 9},
510                 shouldTrash: slots{0, 1}})
511         // Pull to slot 5, the best server with class "special2".
512         bal.try(c, tester{
513                 known:            0,
514                 desired:          map[string]int{"special2": 1},
515                 current:          slots{0, 1},
516                 shouldPull:       slots{5},
517                 shouldPullMounts: []string{"zzzzz-mount-special2000000d"}})
518         // Pull to slot 5 and 9 to get replication 2 in desired class
519         // "special2".
520         bal.try(c, tester{
521                 known:            0,
522                 desired:          map[string]int{"special2": 2},
523                 current:          slots{0, 1},
524                 shouldPull:       slots{5, 9},
525                 shouldPullMounts: []string{"zzzzz-mount-special20000009", "zzzzz-mount-special2000000d"}})
526         // Slot 0 has a replica in "default", slot 1 has a replica
527         // in "special"; we need another replica in "default", i.e.,
528         // on slot 2.
529         bal.try(c, tester{
530                 known:      1,
531                 desired:    map[string]int{"default": 2, "special": 1},
532                 current:    slots{0, 1},
533                 shouldPull: slots{2}})
534         // Pull to best probe position 0 (despite wrong storage class)
535         // if it's impossible to achieve desired replication in the
536         // desired class (only slots 1 and 3 have special2).
537         bal.try(c, tester{
538                 known:      1,
539                 desired:    map[string]int{"special2": 3},
540                 current:    slots{3},
541                 shouldPull: slots{0, 1}})
542         // Trash excess replica.
543         bal.try(c, tester{
544                 known:       3,
545                 desired:     map[string]int{"special": 1},
546                 current:     slots{0, 1},
547                 shouldTrash: slots{1}})
548         // Leave one copy on slot 1 because slot 0 (server 9) only
549         // gives us repl=1.
550         bal.try(c, tester{
551                 known:   3,
552                 desired: map[string]int{"special": 2},
553                 current: slots{0, 1}})
554 }
555
556 // Clear all servers' changesets, balance a single block, and verify
557 // the appropriate changes for that block have been added to the
558 // changesets.
559 func (bal *balancerSuite) try(c *check.C, t tester) {
560         bal.setupLookupTables()
561         blk := &BlockState{
562                 Replicas: bal.replList(t.known, t.current),
563                 Desired:  t.desired,
564         }
565         for i, t := range t.timestamps {
566                 blk.Replicas[i].Mtime = t
567         }
568         for _, srv := range bal.srvs {
569                 srv.ChangeSet = &ChangeSet{}
570         }
571         result := bal.balanceBlock(knownBlkid(t.known), blk)
572
573         var didPull, didTrash slots
574         var didPullMounts, didTrashMounts []string
575         for i, srv := range bal.srvs {
576                 var slot int
577                 for probeOrder, srvNum := range bal.knownRendezvous[t.known] {
578                         if srvNum == i {
579                                 slot = probeOrder
580                         }
581                 }
582                 for _, pull := range srv.Pulls {
583                         didPull = append(didPull, slot)
584                         didPullMounts = append(didPullMounts, pull.To.UUID)
585                         c.Check(pull.SizedDigest, check.Equals, knownBlkid(t.known))
586                 }
587                 for _, trash := range srv.Trashes {
588                         didTrash = append(didTrash, slot)
589                         didTrashMounts = append(didTrashMounts, trash.From.UUID)
590                         c.Check(trash.SizedDigest, check.Equals, knownBlkid(t.known))
591                 }
592         }
593
594         for _, list := range []slots{didPull, didTrash, t.shouldPull, t.shouldTrash} {
595                 sort.Sort(sort.IntSlice(list))
596         }
597         c.Check(didPull, check.DeepEquals, t.shouldPull)
598         c.Check(didTrash, check.DeepEquals, t.shouldTrash)
599         if t.shouldPullMounts != nil {
600                 sort.Strings(didPullMounts)
601                 c.Check(didPullMounts, check.DeepEquals, t.shouldPullMounts)
602         }
603         if t.shouldTrashMounts != nil {
604                 sort.Strings(didTrashMounts)
605                 c.Check(didTrashMounts, check.DeepEquals, t.shouldTrashMounts)
606         }
607         if t.expectResult.have > 0 {
608                 c.Check(result.have, check.Equals, t.expectResult.have)
609         }
610         if t.expectResult.want > 0 {
611                 c.Check(result.want, check.Equals, t.expectResult.want)
612         }
613         if t.expectResult.classState != nil {
614                 c.Check(result.classState, check.DeepEquals, t.expectResult.classState)
615         }
616 }
617
618 // srvList returns the KeepServices, sorted in rendezvous order and
619 // then selected by idx. For example, srvList(3, slots{0, 1, 4})
620 // returns the the first-, second-, and fifth-best servers for storing
621 // bal.knownBlkid(3).
622 func (bal *balancerSuite) srvList(knownBlockID int, order slots) (srvs []*KeepService) {
623         for _, i := range order {
624                 srvs = append(srvs, bal.srvs[bal.knownRendezvous[knownBlockID][i]])
625         }
626         return
627 }
628
629 // replList is like srvList but returns an "existing replicas" slice,
630 // suitable for a BlockState test fixture.
631 func (bal *balancerSuite) replList(knownBlockID int, order slots) (repls []Replica) {
632         nextMnt := map[*KeepService]int{}
633         mtime := time.Now().UnixNano() - (bal.signatureTTL+86400)*1e9
634         for _, srv := range bal.srvList(knownBlockID, order) {
635                 // round-robin repls onto each srv's mounts
636                 n := nextMnt[srv]
637                 nextMnt[srv] = (n + 1) % len(srv.mounts)
638
639                 repls = append(repls, Replica{srv.mounts[n], mtime})
640                 mtime++
641         }
642         return
643 }
644
645 // generate the same data hashes that are tested in
646 // sdk/go/keepclient/root_sorter_test.go
647 func knownBlkid(i int) arvados.SizedDigest {
648         return arvados.SizedDigest(fmt.Sprintf("%x+64", md5.Sum([]byte(fmt.Sprintf("%064x", i)))))
649 }