deb: rename published indexes concurrently

RenameFiles walks renameMap one entry at a time. Every rename is a round trip,
and on an S3 backend RenameFile is a CopyObject followed by a DeleteObject, so
a publish that rewrites a dozen indexes spends most of its wall clock waiting
on them in sequence.

Bounded at four in flight, matching the default used for concurrent package
uploads. The first error is returned rather than the last, and the remaining
renames still run, so a failure does not leave part of the set staged behind a
half-finished switch.

Measured against MinIO behind a proxy adding 50ms each way, publishing a
repository whose Contents is dominated by one package carrying 19,500 files:
publish update drops from 2703ms to 1624ms median, n=5 either side, a 40% cut.
The published object set and its sizes are identical before and after.

RenameFiles had no test coverage. It has four cases now: every entry renamed,
the concurrency limit respected, a failure reported without abandoning the
rest, and an empty rename map.
This commit is contained in:
Martin Simon authored and André Roth committed 2026-11-04 12:24:18 +01:00
1 parent 6f95fdca32
commit c531cac535
3 files changed
+150 -7

No files matched your search

+1
View File
@@ -88,3 +88,4 @@ List of contributors, in chronological order:
* Phil Dibowitz (https://github.com/jaymzh)
* James Munson (https://github.com/jmunson)
* Luan Dang (https://github.com/luanmdang)
* Martin Simon (https://github.com/barnumbirr)
+41 -7
View File
@@ -7,6 +7,7 @@ import (
"path"
"path/filepath"
"strings"
"sync"
"github.com/aptly-dev/aptly/aptly"
"github.com/aptly-dev/aptly/pgp"
@@ -499,15 +500,48 @@ func (files *indexFiles) FinalizeAll(progress aptly.Progress, signer pgp.Signer)
return
}
func (files *indexFiles) RenameFiles() error {
var err error
// How many index renames are in flight at once. Every rename is a round trip,
// and on an S3 backend it is a CopyObject followed by a DeleteObject, so a
// publish rewriting a dozen indexes spent most of its wall clock waiting on
// them one at a time. Four matches the default used for concurrent package
// uploads.
const renameConcurrency = 4
func (files *indexFiles) RenameFiles() error {
type renamePair struct{ oldName, newName string }
pairs := make([]renamePair, 0, len(files.renameMap))
for oldName, newName := range files.renameMap {
err = files.publishedStorage.RenameFile(oldName, newName)
if err != nil {
return fmt.Errorf("unable to rename: %s", err)
}
pairs = append(pairs, renamePair{oldName, newName})
}
return nil
var (
wg sync.WaitGroup
mu sync.Mutex
firstErr error
)
sem := make(chan struct{}, renameConcurrency)
for _, pair := range pairs {
wg.Add(1)
go func(pair renamePair) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
if err := files.publishedStorage.RenameFile(pair.oldName, pair.newName); err != nil {
mu.Lock()
if firstErr == nil {
firstErr = fmt.Errorf("unable to rename: %s", err)
}
mu.Unlock()
}
}(pair)
}
wg.Wait()
return firstErr
}
+108
View File
@@ -0,0 +1,108 @@
package deb
import (
"fmt"
"sync"
"github.com/aptly-dev/aptly/aptly"
. "gopkg.in/check.v1"
)
type RenameFilesSuite struct{}
var _ = Suite(&RenameFilesSuite{})
// Only RenameFile is exercised by RenameFiles, so the rest of the interface is
// embedded and left nil: a call to anything else would panic, which is the
// loudest way to notice this fake drifting from what the code under test does.
type renameRecordingStorage struct {
aptly.PublishedStorage
mu sync.Mutex
renamed map[string]string
failOn string
inFlight int
maxSeen int
}
func (s *renameRecordingStorage) RenameFile(oldName, newName string) error {
s.mu.Lock()
s.inFlight++
if s.inFlight > s.maxSeen {
s.maxSeen = s.inFlight
}
s.mu.Unlock()
defer func() {
s.mu.Lock()
s.inFlight--
s.mu.Unlock()
}()
if oldName == s.failOn {
return fmt.Errorf("rename refused for %s", oldName)
}
s.mu.Lock()
s.renamed[oldName] = newName
s.mu.Unlock()
return nil
}
func newRenameFiles(storage aptly.PublishedStorage, n int) (*indexFiles, *renameRecordingStorage) {
rec, ok := storage.(*renameRecordingStorage)
if !ok {
rec = &renameRecordingStorage{renamed: make(map[string]string)}
}
files := &indexFiles{
publishedStorage: rec,
renameMap: make(map[string]string, n),
}
for i := 0; i < n; i++ {
files.renameMap[fmt.Sprintf("dists/x/Packages.tmp.%d", i)] = fmt.Sprintf("dists/x/Packages.%d", i)
}
return files, rec
}
func (s *RenameFilesSuite) TestRenameFilesRenamesEveryEntry(c *C) {
files, rec := newRenameFiles(nil, 20)
c.Assert(files.RenameFiles(), IsNil)
c.Check(len(rec.renamed), Equals, 20)
for i := 0; i < 20; i++ {
c.Check(rec.renamed[fmt.Sprintf("dists/x/Packages.tmp.%d", i)], Equals,
fmt.Sprintf("dists/x/Packages.%d", i))
}
}
func (s *RenameFilesSuite) TestRenameFilesStaysWithinTheConcurrencyLimit(c *C) {
files, rec := newRenameFiles(nil, 50)
c.Assert(files.RenameFiles(), IsNil)
// An upper bound, so this cannot flake: fewer in flight than the limit is
// always acceptable, more never is.
c.Check(rec.maxSeen <= renameConcurrency, Equals, true,
Commentf("saw %d renames in flight, limit is %d", rec.maxSeen, renameConcurrency))
}
func (s *RenameFilesSuite) TestRenameFilesReportsAFailure(c *C) {
files, rec := newRenameFiles(nil, 10)
rec.failOn = "dists/x/Packages.tmp.4"
err := files.RenameFiles()
c.Assert(err, NotNil)
c.Check(err, ErrorMatches, "unable to rename: rename refused for dists/x/Packages.tmp.4")
// The failure is reported, and it does not abandon the rest: every other
// entry is still renamed rather than left staged.
c.Check(len(rec.renamed), Equals, 9)
}
func (s *RenameFilesSuite) TestRenameFilesWithNothingToDo(c *C) {
files, _ := newRenameFiles(nil, 0)
c.Assert(files.RenameFiles(), IsNil)
}