Repository: kopia/kopia
Pull request #4165 (2024-10-09 04:47:57)

refactor(server): handle `ErrServerClosed` where it occurs
Checks whether any of the `httpServer.Serve*()` calls returns `ErrServerClosed`.

Hhandles `ErrServerClosed` inside the `startServerWithOptionalTLS` function instead of propagating it up.  This means that `startServerWithOptionalTLS` returns a nil error when the HTTP server is closed, so the caller does not need to check for `ErrServerClosed`

No functional changes otherwise.


Patch:
diff --git a/cli/app.go b/cli/app.go
index 17a49276..43083575 100644
--- a/cli/app.go
+++ b/cli/app.go
@@ -564,7 +564,7 @@ func (c *App) maybeRepositoryAction(act func(ctx context.Context, rep repo.Repos
 
 		err = act(ctx, rep)
 
-		if rep != nil && !mode.disableMaintenance {
+		if rep != nil && err == nil && !mode.disableMaintenance {
 			if merr := c.maybeRunMaintenance(ctx, rep); merr != nil {
 				log(ctx).Errorf("error running maintenance: %v", merr)
 			}
diff --git a/cli/command_server.go b/cli/command_server.go
index a4db704f..4eee5a0c 100644
--- a/cli/command_server.go
+++ b/cli/command_server.go
@@ -42,7 +42,7 @@ type serverClientFlags struct {
 }
 
 func (c *serverClientFlags) setup(svc appServices, cmd *kingpin.CmdClause) {
-	c.serverUsername = "server-control"
+	c.serverUsername = defaultServerControlUsername
 
 	cmd.Flag("address", "Address of the server to connect to").Envar(svc.EnvName("KOPIA_SERVER_ADDRESS")).Default("http://127.0.0.1:51515").StringVar(&c.serverAddress)
 	cmd.Flag("server-control-username", "Server control username").Envar(svc.EnvName("KOPIA_SERVER_USERNAME")).StringVar(&c.serverUsername)
diff --git a/cli/command_server_start.go b/cli/command_server_start.go
index 4ecd14e0..98ff726a 100644
--- a/cli/command_server_start.go
+++ b/cli/command_server_start.go
@@ -24,7 +24,10 @@
 	"github.com/kopia/kopia/repo"
 )
 
-const serverRandomPasswordLength = 32
+const (
+	defaultServerControlUsername = "server-control"
+	serverRandomPasswordLength   = 32
+)
 
 type commandServerStart struct {
 	co connectOptions
@@ -93,7 +96,7 @@ func (c *commandServerStart) setup(svc advancedAppServices, parent commandParent
 	cmd.Flag("htpasswd-file", "Path to htpasswd file that contains allowed user@hostname entries").Hidden().ExistingFileVar(&c.serverStartHtpasswdFile)
 
 	cmd.Flag("random-server-control-password", "Generate random server control password and print to stderr").Hidden().BoolVar(&c.randomServerControlPassword)
-	cmd.Flag("server-control-username", "Server control username").Default("server-control").Envar(svc.EnvName("KOPIA_SERVER_CONTROL_USER")).StringVar(&c.serverControlUsername)
+	cmd.Flag("server-control-username", "Server control username").Default(defaultServerControlUsername).Envar(svc.EnvName("KOPIA_SERVER_CONTROL_USER")).StringVar(&c.serverControlUsername)
 	cmd.Flag("server-control-password", "Server control password").PlaceHolder("PASSWORD").Envar(svc.EnvName("KOPIA_SERVER_CONTROL_PASSWORD")).StringVar(&c.serverControlPassword)
 
 	cmd.Flag("auth-cookie-signing-key", "Force particular auth cookie signing key").Envar(svc.EnvName("KOPIA_AUTH_COOKIE_SIGNING_KEY")).Hidden().StringVar(&c.serverAuthCookieSingingKey)
@@ -265,12 +268,7 @@ func (c *commandServerStart) run(ctx context.Context) (reterr error) {
 
 	onExternalConfigReloadRequest(srv.Refresh)
 
-	err = c.startServerWithOptionalTLS(ctx, httpServer)
-	if !errors.Is(err, http.ErrServerClosed) {
-		return err
-	}
-
-	return nil
+	return c.startServerWithOptionalTLS(ctx, httpServer)
 }
 
 func shutdownHTTPServer(ctx context.Context, httpServer *http.Server) {
diff --git a/cli/command_server_tls.go b/cli/command_server_tls.go
index 53c38e67..019af0c0 100644
--- a/cli/command_server_tls.go
+++ b/cli/command_server_tls.go
@@ -122,7 +122,7 @@ func (c *commandServerStart) startServerWithOptionalTLSAndListener(ctx context.C
 		fmt.Fprintf(c.out.stderr(), "SERVER ADDRESS: %shttps://%v\n", udsPfx, httpServer.Addr) //nolint:errcheck
 		c.showServerUIPrompt(ctx)
 
-		return errors.Wrap(httpServer.ServeTLS(listener, c.serverStartTLSCertFile, c.serverStartTLSKeyFile), "error starting TLS server")
+		return checkErrServerClosed(ctx, httpServer.ServeTLS(listener, c.serverStartTLSCertFile, c.serverStartTLSKeyFile), "error starting TLS server")
 
 	case c.serverStartTLSGenerateCert:
 		// PEM files not provided, generate in-memory TLS cert/key but don't persit.
@@ -158,7 +158,7 @@ func (c *commandServerStart) startServerWithOptionalTLSAndListener(ctx context.C
 		fmt.Fprintf(c.out.stderr(), "SERVER ADDRESS: %shttps://%v\n", udsPfx, httpServer.Addr) //nolint:errcheck
 		c.showServerUIPrompt(ctx)
 
-		return errors.Wrap(httpServer.ServeTLS(listener, "", ""), "error starting TLS server")
+		return checkErrServerClosed(ctx, httpServer.ServeTLS(listener, "", ""), "error starting TLS server")
 
 	default:
 		if !c.serverStartInsecure {
@@ -168,7 +168,7 @@ func (c *commandServerStart) startServerWithOptionalTLSAndListener(ctx context.C
 		fmt.Fprintf(c.out.stderr(), "SERVER ADDRESS: %shttp://%v\n", udsPfx, httpServer.Addr) //nolint:errcheck
 		c.showServerUIPrompt(ctx)
 
-		return errors.Wrap(httpServer.Serve(listener), "error starting server")
+		return checkErrServerClosed(ctx, httpServer.Serve(listener), "error starting server")
 	}
 }
 
@@ -177,3 +177,13 @@ func (c *commandServerStart) showServerUIPrompt(ctx context.Context) {
 		log(ctx).Info("Open the address above in a web browser to use the UI.")
 	}
 }
+
+func checkErrServerClosed(ctx context.Context, err error, msg string) error {
+	if errors.Is(err, http.ErrServerClosed) {
+		log(ctx).Debug("HTTP server closed:", err)
+
+		return nil
+	}
+
+	return errors.Wrap(err, msg)
+}
diff --git a/go.mod b/go.mod
index 0abb4bb1..913e5f06 100644
--- a/go.mod
+++ b/go.mod
@@ -10,11 +10,11 @@ require (
 	github.com/alecthomas/kingpin/v2 v2.4.0
 	github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137
 	github.com/chmduquesne/rollinghash v4.0.0+incompatible
-	github.com/chromedp/cdproto v0.0.0-20240801214329-3f85d328b335
-	github.com/chromedp/chromedp v0.10.0
+	github.com/chromedp/cdproto v0.0.0-20241003230502-a4a8f7c660df
+	github.com/chromedp/chromedp v0.11.0
 	github.com/coreos/go-systemd/v22 v22.5.0
 	github.com/dustinkirkland/golang-petname v0.0.0-20191129215211-8e5a1ed0cff0
-	github.com/edsrzf/mmap-go v1.1.0
+	github.com/edsrzf/mmap-go v1.2.0
 	github.com/fatih/color v1.17.0
 	github.com/foomo/htpasswd v0.0.0-20200116085101-e3a90e78da9c
 	github.com/gofrs/flock v0.12.1
@@ -25,7 +25,7 @@ require (
 	github.com/gorilla/mux v1.8.1
 	github.com/hanwen/go-fuse/v2 v2.6.1
 	github.com/hashicorp/cronexpr v1.1.2
-	github.com/klauspost/compress v1.17.10
+	github.com/klauspost/compress v1.17.11
 	github.com/klauspost/pgzip v1.2.6
 	github.com/klauspost/reedsolomon v1.12.4
 	github.com/kopia/htmluibuild v0.0.1-0.20241006020113-c37037c013ee
@@ -35,6 +35,7 @@ require (
 	github.com/mocktools/go-smtp-mock/v2 v2.3.1
 	github.com/mxk/go-vss v1.2.0
 	github.com/natefinch/atomic v1.0.1
+	github.com/petar/GoLLRB v0.0.0-20210522233825-ae3b015fd3e9
 	github.com/pierrec/lz4 v2.6.1+incompatible
 	github.com/pkg/errors v0.9.1
 	github.com/pkg/profile v1.7.0
@@ -49,10 +50,10 @@ require (
 	github.com/tg123/go-htpasswd v1.2.2
 	github.com/zalando/go-keyring v0.2.5
 	github.com/zeebo/blake3 v0.2.4
-	go.opentelemetry.io/otel v1.30.0
-	go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.30.0
-	go.opentelemetry.io/otel/sdk v1.30.0
-	go.opentelemetry.io/otel/trace v1.30.0
+	go.opentelemetry.io/otel v1.31.0
+	go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.31.0
+	go.opentelemetry.io/otel/sdk v1.31.0
+	go.opentelemetry.io/otel/trace v1.31.0
 	go.uber.org/multierr v1.11.0
 	go.uber.org/zap v1.27.0
 	golang.org/x/crypto v0.28.0
@@ -135,14 +136,14 @@ require (
 	go.opentelemetry.io/contrib/detectors/gcp v1.29.0 // indirect
 	go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 // indirect
 	go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 // indirect
-	go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.30.0 // indirect
-	go.opentelemetry.io/otel/metric v1.30.0 // indirect
+	go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.31.0 // indirect
+	go.opentelemetry.io/otel/metric v1.31.0 // indirect
 	go.opentelemetry.io/otel/sdk/metric v1.29.0 // indirect
 	go.opentelemetry.io/proto/otlp v1.3.1 // indirect
 	golang.org/x/time v0.6.0 // indirect
 	google.golang.org/genproto v0.0.0-20240903143218-8af14fe29dc1 // indirect
-	google.golang.org/genproto/googleapis/api v0.0.0-20240903143218-8af14fe29dc1 // indirect
-	google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 // indirect
+	google.golang.org/genproto/googleapis/api v0.0.0-20241007155032-5fefd90f89a9 // indirect
+	google.golang.org/genproto/googleapis/rpc v0.0.0-20241007155032-5fefd90f89a9 // indirect
 	google.golang.org/grpc/stats/opentelemetry v0.0.0-20240907200651-3ffb98b2c93a // indirect
 	gopkg.in/yaml.v3 v3.0.1 // indirect
 )
diff --git a/go.sum b/go.sum
index c5a5f951..0f7fd362 100644
--- a/go.sum
+++ b/go.sum
@@ -62,10 +62,10 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF
 github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
 github.com/chmduquesne/rollinghash v4.0.0+incompatible h1:hnREQO+DXjqIw3rUTzWN7/+Dpw+N5Um8zpKV0JOEgbo=
 github.com/chmduquesne/rollinghash v4.0.0+incompatible/go.mod h1:Uc2I36RRfTAf7Dge82bi3RU0OQUmXT9iweIcPqvr8A0=
-github.com/chromedp/cdproto v0.0.0-20240801214329-3f85d328b335 h1:bATMoZLH2QGct1kzDxfmeBUQI/QhQvB0mBrOTct+YlQ=
-github.com/chromedp/cdproto v0.0.0-20240801214329-3f85d328b335/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs=
-github.com/chromedp/chromedp v0.10.0 h1:bRclRYVpMm/UVD76+1HcRW9eV3l58rFfy7AdBvKab1E=
-github.com/chromedp/chromedp v0.10.0/go.mod h1:ei/1ncZIqXX1YnAYDkxhD4gzBgavMEUu7JCKvztdomE=
+github.com/chromedp/cdproto v0.0.0-20241003230502-a4a8f7c660df h1:cbtSn19AtqQha1cxmP2Qvgd3fFMz51AeAEKLJMyEUhc=
+github.com/chromedp/cdproto v0.0.0-20241003230502-a4a8f7c660df/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs=
+github.com/chromedp/chromedp v0.11.0 h1:1PT6O4g39sBAFjlljIHTpxmCSk8meeYL6+R+oXH4bWA=
+github.com/chromedp/chromedp v0.11.0/go.mod h1:jsD7OHrX0Qmskqb5Y4fn4jHnqquqW22rkMFgKbECsqg=
 github.com/chromedp/sysutil v1.0.0 h1:+ZxhTpfpZlmchB58ih/LBHX52ky7w2VhQVKQMucy3Ic=
 github.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moAV0xufSww=
 github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
@@ -88,8 +88,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
 github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
 github.com/dustinkirkland/golang-petname v0.0.0-20191129215211-8e5a1ed0cff0 h1:90Ly+6UfUypEF6vvvW5rQIv9opIL8CbmW9FT20LDQoY=
 github.com/dustinkirkland/golang-petname v0.0.0-20191129215211-8e5a1ed0cff0/go.mod h1:V+Qd57rJe8gd4eiGzZyg4h54VLHmYVVw54iMnlAMrF8=
-github.com/edsrzf/mmap-go v1.1.0 h1:6EUwBLQ/Mcr1EYLE4Tn1VdW1A4ckqCQWZBw8Hr0kjpQ=
-github.com/edsrzf/mmap-go v1.1.0/go.mod h1:19H/e8pUPLicwkyNgOykDXkJ9F0MHE+Z52B8EIth78Q=
+github.com/edsrzf/mmap-go v1.2.0 h1:hXLYlkbaPzt1SaQk+anYwKSRNhufIDCchSPkUD6dD84=
+github.com/edsrzf/mmap-go v1.2.0/go.mod h1:19H/e8pUPLicwkyNgOykDXkJ9F0MHE+Z52B8EIth78Q=
 github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
 github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
 github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
@@ -190,8 +190,8 @@ github.com/hashicorp/cronexpr v1.1.2/go.mod h1:P4wA0KBl9C5q2hABiMO7cp6jcIg96CDh1
 github.com/ianlancetaylor/demangle v0.0.0-20210905161508-09a460cdf81d/go.mod h1:aYm2/VgdVmcIU8iMfdMvDMsRAQjcfZSKFby6HOFvi/w=
 github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
 github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
-github.com/klauspost/compress v1.17.10 h1:oXAz+Vh0PMUvJczoi+flxpnBEPxoER1IaAnU/NMPtT0=
-github.com/klauspost/compress v1.17.10/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
+github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
+github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
 github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
 github.com/klauspost/cpuid/v2 v2.2.8 h1:+StwCXwm9PdpiEkPyzBXIy+M9KUb4ODm0Zarf1kS5BM=
 github.com/klauspost/cpuid/v2 v2.2.8/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
@@ -238,6 +238,8 @@ github.com/natefinch/atomic v1.0.1 h1:ZPYKxkqQOx3KZ+RsbnP/YsgvxWQPGxjC0oBt2AhwV0
 github.com/natefinch/atomic v1.0.1/go.mod h1:N/D/ELrljoqDyT3rZrsUmtsuzvHkeB/wWjHV22AZRbM=
 github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
 github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
+github.com/petar/GoLLRB v0.0.0-20210522233825-ae3b015fd3e9 h1:1/WtZae0yGtPq+TI6+Tv1WTxkukpXeMlviSxvL7SRgk=
+github.com/petar/GoLLRB v0.0.0-20210522233825-ae3b015fd3e9/go.mod h1:x3N5drFsm2uilKKuuYo6LdyD8vZAW55sH/9w+pbo1sw=
 github.com/pierrec/lz4 v2.6.1+incompatible h1:9UY3+iC23yxF0UfGaYrGplQ+79Rg+h/q9FV9ix19jjM=
 github.com/pierrec/lz4 v2.6.1+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY=
 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
@@ -265,8 +267,8 @@ github.com/prometheus/common v0.60.0/go.mod h1:h0LYf1R1deLSKtD4Vdg8gy4RuOvENW2J/
 github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
 github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
 github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
-github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
-github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
+github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
+github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
 github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
 github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
 github.com/sanity-io/litter v1.5.5 h1:iE+sBxPBzoK6uaEP5Lt3fHNgpKcHXc/A2HGETy0uJQo=
@@ -309,20 +311,20 @@ go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.5
 go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0/go.mod h1:B9yO6b04uB80CzjedvewuqDhxJxi11s7/GtiGa8bAjI=
 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk=
 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8=
-go.opentelemetry.io/otel v1.30.0 h1:F2t8sK4qf1fAmY9ua4ohFS/K+FUuOPemHUIXHtktrts=
-go.opentelemetry.io/otel v1.30.0/go.mod h1:tFw4Br9b7fOS+uEao81PJjVMjW/5fvNCbpsDIXqP0pc=
-go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.30.0 h1:lsInsfvhVIfOI6qHVyysXMNDnjO9Npvl7tlDPJFBVd4=
-go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.30.0/go.mod h1:KQsVNh4OjgjTG0G6EiNi1jVpnaeeKsKMRwbLN+f1+8M=
-go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.30.0 h1:m0yTiGDLUvVYaTFbAvCkVYIYcvwKt3G7OLoN77NUs/8=
-go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.30.0/go.mod h1:wBQbT4UekBfegL2nx0Xk1vBcnzyBPsIVm9hRG4fYcr4=
-go.opentelemetry.io/otel/metric v1.30.0 h1:4xNulvn9gjzo4hjg+wzIKG7iNFEaBMX00Qd4QIZs7+w=
-go.opentelemetry.io/otel/metric v1.30.0/go.mod h1:aXTfST94tswhWEb+5QjlSqG+cZlmyXy/u8jFpor3WqQ=
-go.opentelemetry.io/otel/sdk v1.30.0 h1:cHdik6irO49R5IysVhdn8oaiR9m8XluDaJAs4DfOrYE=
-go.opentelemetry.io/otel/sdk v1.30.0/go.mod h1:p14X4Ok8S+sygzblytT1nqG98QG2KYKv++HE0LY/mhg=
+go.opentelemetry.io/otel v1.31.0 h1:NsJcKPIW0D0H3NgzPDHmo0WW6SptzPdqg/L1zsIm2hY=
+go.opentelemetry.io/otel v1.31.0/go.mod h1:O0C14Yl9FgkjqcCZAsE053C13OaddMYr/hz6clDkEJE=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.31.0 h1:K0XaT3DwHAcV4nKLzcQvwAgSyisUghWoY20I7huthMk=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.31.0/go.mod h1:B5Ki776z/MBnVha1Nzwp5arlzBbE3+1jk+pGmaP5HME=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.31.0 h1:FFeLy03iVTXP6ffeN2iXrxfGsZGCjVx0/4KlizjyBwU=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.31.0/go.mod h1:TMu73/k1CP8nBUpDLc71Wj/Kf7ZS9FK5b53VapRsP9o=
+go.opentelemetry.io/otel/metric v1.31.0 h1:FSErL0ATQAmYHUIzSezZibnyVlft1ybhy4ozRPcF2fE=
+go.opentelemetry.io/otel/metric v1.31.0/go.mod h1:C3dEloVbLuYoX41KpmAhOqNriGbA+qqH6PQ5E5mUfnY=
+go.opentelemetry.io/otel/sdk v1.31.0 h1:xLY3abVHYZ5HSfOg3l2E5LUj2Cwva5Y7yGxnSW9H5Gk=
+go.opentelemetry.io/otel/sdk v1.31.0/go.mod h1:TfRbMdhvxIIr/B2N2LQW2S5v9m3gOQ/08KsbbO5BPT0=
 go.opentelemetry.io/otel/sdk/metric v1.29.0 h1:K2CfmJohnRgvZ9UAj2/FhIf/okdWcNdBwe1m8xFXiSY=
 go.opentelemetry.io/otel/sdk/metric v1.29.0/go.mod h1:6zZLdCl2fkauYoZIOn/soQIDSWFmNSRcICarHfuhNJQ=
-go.opentelemetry.io/otel/trace v1.30.0 h1:7UBkkYzeg3C7kQX8VAidWh2biiQbtAKjyIML8dQ9wmc=
-go.opentelemetry.io/otel/trace v1.30.0/go.mod h1:5EyKqTzzmyqB9bwtCCq6pDLktPK6fmGf/Dph+8VI02o=
+go.opentelemetry.io/otel/trace v1.31.0 h1:ffjsj1aRouKewfr85U2aGagJ46+MvodynlQ1HYdmJys=
+go.opentelemetry.io/otel/trace v1.31.0/go.mod h1:TXZkRk7SM2ZQLtR6eoAWQFIHPvzQ06FJAsO1tJg480A=
 go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0=
 go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8=
 go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
@@ -381,7 +383,6 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc
 golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
 golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
@@ -415,10 +416,10 @@ google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98
 google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
 google.golang.org/genproto v0.0.0-20240903143218-8af14fe29dc1 h1:BulPr26Jqjnd4eYDVe+YvyR7Yc2vJGkO5/0UxD0/jZU=
 google.golang.org/genproto v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:hL97c3SYopEHblzpxRL4lSs523++l8DYxGM1FQiYmb4=
-google.golang.org/genproto/googleapis/api v0.0.0-20240903143218-8af14fe29dc1 h1:hjSy6tcFQZ171igDaN5QHOw2n6vx40juYbC/x67CEhc=
-google.golang.org/genproto/googleapis/api v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:qpvKtACPCQhAdu3PyQgV4l3LMXZEtft7y8QcarRsp9I=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 h1:pPJltXNxVzT4pK9yD8vR9X75DaWYYmLGMsEvBfFQZzQ=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU=
+google.golang.org/genproto/googleapis/api v0.0.0-20241007155032-5fefd90f89a9 h1:T6rh4haD3GVYsgEfWExoCZA2o2FmbNyKpTuAxbEFPTg=
+google.golang.org/genproto/googleapis/api v0.0.0-20241007155032-5fefd90f89a9/go.mod h1:wp2WsuBYj6j8wUdo3ToZsdxxixbvQNAHqVJrTgi5E5M=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20241007155032-5fefd90f89a9 h1:QCqS/PdaHTSWGvupk2F/ehwHtGc0/GYkT+3GAcR1CCc=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20241007155032-5fefd90f89a9/go.mod h1:GX3210XPVPUjJbTUbvwI8f2IpZDMZuPJWDzDuebbviI=
 google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
 google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
 google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
diff --git a/internal/server/api_notification_profile.go b/internal/server/api_notification_profile.go
new file mode 100644
index 00000000..845dcf0d
--- /dev/null
+++ b/internal/server/api_notification_profile.go
@@ -0,0 +1,62 @@
+package server
+
+import (
+	"context"
+	"encoding/json"
+
+	"github.com/kopia/kopia/internal/serverapi"
+	"github.com/kopia/kopia/notification/notifyprofile"
+	"github.com/kopia/kopia/repo"
+)
+
+func handleNotificationProfileCreate(ctx context.Context, rc requestContext) (any, *apiError) {
+	var cfg notifyprofile.Config
+
+	if err := json.Unmarshal(rc.body, &cfg); err != nil {
+		return nil, requestError(serverapi.ErrorMalformedRequest, "malformed request body: "+string(rc.body))
+	}
+
+	if err := repo.WriteSession(ctx, rc.rep, repo.WriteSessionOptions{
+		Purpose: "NotificationProfileCreate",
+	}, func(ctx context.Context, w repo.RepositoryWriter) error {
+		return notifyprofile.SaveProfile(ctx, w, cfg)
+	}); err != nil {
+		return nil, internalServerError(err)
+	}
+
+	return &serverapi.Empty{}, nil
+}
+
+func handleNotificationProfileGet(ctx context.Context, rc requestContext) (any, *apiError) {
+	cfg, ok, err := notifyprofile.GetProfile(ctx, rc.rep, rc.muxVar("profileName"))
+	if err != nil {
+		return nil, internalServerError(err)
+	}
+
+	if !ok {
+		return nil, notFoundError("profile not found")
+	}
+
+	return cfg, nil
+}
+
+func handleNotificationProfileDelete(ctx context.Context, rc requestContext) (any, *apiError) {
+	if err := repo.WriteSession(ctx, rc.rep, repo.WriteSessionOptions{
+		Purpose: "NotificationProfileDelete",
+	}, func(ctx context.Context, w repo.RepositoryWriter) error {
+		return notifyprofile.DeleteProfile(ctx, w, rc.muxVar("profileName"))
+	}); err != nil {
+		return nil, internalServerError(err)
+	}
+
+	return &serverapi.Empty{}, nil
+}
+
+func handleNotificationProfileList(ctx context.Context, rc requestContext) (any, *apiError) {
+	profiles, err := notifyprofile.ListProfiles(ctx, rc.rep)
+	if err != nil {
+		return nil, internalServerError(err)
+	}
+
+	return profiles, nil
+}
diff --git a/internal/server/server.go b/internal/server/server.go
index 7d2e6d5a..a0e4e516 100644
--- a/internal/server/server.go
+++ b/internal/server/server.go
@@ -158,6 +158,11 @@ func (s *Server) SetupHTMLUIAPIHandlers(m *mux.Router) {
 	m.HandleFunc("/api/v1/tasks/{taskID}", s.handleUIPossiblyNotConnected(handleTaskInfo)).Methods(http.MethodGet)
 	m.HandleFunc("/api/v1/tasks/{taskID}/logs", s.handleUIPossiblyNotConnected(handleTaskLogs)).Methods(http.MethodGet)
 	m.HandleFunc("/api/v1/tasks/{taskID}/cancel", s.handleUIPossiblyNotConnected(handleTaskCancel)).Methods(http.MethodPost)
+
+	m.HandleFunc("/api/v1/notificationProfiles", s.handleUI(handleNotificationProfileCreate)).Methods(http.MethodPost)
+	m.HandleFunc("/api/v1/notificationProfiles/{profileName}", s.handleUI(handleNotificationProfileDelete)).Methods(http.MethodDelete)
+	m.HandleFunc("/api/v1/notificationProfiles/{profileName}", s.handleUI(handleNotificationProfileGet)).Methods(http.MethodGet)
+	m.HandleFunc("/api/v1/notificationProfiles", s.handleUI(handleNotificationProfileList)).Methods(http.MethodGet)
 }
 
 // SetupControlAPIHandlers registers control API handlers.
diff --git a/repo/content/index/index_builder.go b/repo/content/index/index_builder.go
index 05f54482..3647b38a 100644
--- a/repo/content/index/index_builder.go
+++ b/repo/content/index/index_builder.go
@@ -18,6 +18,11 @@
 // Builder prepares and writes content index.
 type Builder map[ID]Info
 
+// BuilderCreator is an interface for caller to add indexes to builders.
+type BuilderCreator interface {
+	Add(info Info)
+}
+
 // Clone returns a deep Clone of the Builder.
 func (b Builder) Clone() Builder {
 	if b == nil {
@@ -38,7 +43,7 @@ func (b Builder) Add(i Info) {
 	cid := i.ContentID
 
 	old, found := b[cid]
-	if !found || contentInfoGreaterThanStruct(i, old) {
+	if !found || contentInfoGreaterThanStruct(&i, &old) {
 		b[cid] = i
 	}
 }
@@ -63,8 +68,8 @@ func init() {
 // sortedContents returns the list of []Info sorted lexicographically using bucket sort
 // sorting is optimized based on the format of content IDs (optional single-character
 // alphanumeric prefix (0-9a-z), followed by hexadecimal digits (0-9a-f).
-func (b Builder) sortedContents() []Info {
-	var buckets [36 * 16][]Info
+func (b Builder) sortedContents() []*Info {
+	var buckets [36 * 16][]*Info
 
 	// phase 1 - bucketize into 576 (36 *16) separate lists
 	// by first [0-9a-z] and second character [0-9a-f].
@@ -75,7 +80,7 @@ func (b Builder) sortedContents() []Info {
 		// first: 0..35, second: 0..15
 		buck := first<<4 + second //nolint:mnd
 
-		buckets[buck] = append(buckets[buck], v)
+		buckets[buck] = append(buckets[buck], &v)
 	}
 
 	// phase 2 - sort each non-empty bucket in parallel using goroutines
@@ -104,7 +109,7 @@ func (b Builder) sortedContents() []Info {
 	wg.Wait()
 
 	// Phase 3 - merge results from all buckets.
-	result := make([]Info, 0, len(b))
+	result := make([]*Info, 0, len(b))
 
 	for i := range len(buckets) {
 		result = append(result, buckets[i]...)
@@ -134,12 +139,16 @@ func (b Builder) Build(output io.Writer, version int) error {
 
 // BuildStable writes the pack index to the provided output.
 func (b Builder) BuildStable(output io.Writer, version int) error {
+	return buildSortedContents(b.sortedContents(), output, version)
+}
+
+func buildSortedContents(items []*Info, output io.Writer, version int) error {
 	switch version {
 	case Version1:
-		return b.buildV1(output)
+		return buildV1(items, output)
 
 	case Version2:
-		return b.buildV2(output)
+		return buildV2(items, output)
 
 	default:
 		return errors.Errorf("unsupported index version: %v", version)
diff --git a/repo/content/index/index_v1.go b/repo/content/index/index_v1.go
index cae8bfd1..002657c4 100644
--- a/repo/content/index/index_v1.go
+++ b/repo/content/index/index_v1.go
@@ -266,8 +266,7 @@ type indexBuilderV1 struct {
 }
 
 // buildV1 writes the pack index to the provided output.
-func (b Builder) buildV1(output io.Writer) error {
-	allContents := b.sortedContents()
+func buildV1(allContents []*Info, output io.Writer) error {
 	b1 := &indexBuilderV1{
 		packBlobIDOffsets: map[blob.ID]uint32{},
 		keyLength:         -1,
@@ -307,7 +306,7 @@ func (b Builder) buildV1(output io.Writer) error {
 	return errors.Wrap(w.Flush(), "error flushing index")
 }
 
-func (b *indexBuilderV1) prepareExtraData(allContents []Info) []byte {
+func (b *indexBuilderV1) prepareExtraData(allContents []*Info) []byte {
 	var extraData []byte
 
 	var hashBuf [maxContentIDSize]byte
@@ -330,7 +329,7 @@ func (b *indexBuilderV1) prepareExtraData(allContents []Info) []byte {
 	return extraData
 }
 
-func (b *indexBuilderV1) writeEntry(w io.Writer, it Info, entry []byte) error {
+func (b *indexBuilderV1) writeEntry(w io.Writer, it *Info, entry []byte) error {
 	var hashBuf [maxContentIDSize]byte
 
 	k := contentIDToBytes(hashBuf[:0], it.ContentID)
@@ -362,7 +361,7 @@ func (b *indexBuilderV1) writeEntry(w io.Writer, it Info, entry []byte) error {
 	return nil
 }
 
-func (b *indexBuilderV1) formatEntry(entry []byte, it Info) error {
+func (b *indexBuilderV1) formatEntry(entry []byte, it *Info) error {
 	entryTimestampAndFlags := entry[0:8]
 	entryPackFileOffset := entry[8:12]
 	entryPackedOffset := entry[12:16]
diff --git a/repo/content/index/index_v2.go b/repo/content/index/index_v2.go
index 10e34ece..0c8adfb6 100644
--- a/repo/content/index/index_v2.go
+++ b/repo/content/index/index_v2.go
@@ -373,7 +373,7 @@ type indexBuilderV2 struct {
 	baseTimestamp          int64
 }
 
-func indexV2FormatInfoFromInfo(v Info) indexV2FormatInfo {
+func indexV2FormatInfoFromInfo(v *Info) indexV2FormatInfo {
 	return indexV2FormatInfo{
 		formatVersion:       v.FormatVersion,
 		compressionHeaderID: v.CompressionHeaderID,
@@ -382,7 +382,7 @@ func indexV2FormatInfoFromInfo(v Info) indexV2FormatInfo {
 }
 
 // buildUniqueFormatToIndexMap builds a map of unique indexV2FormatInfo to their numeric identifiers.
-func buildUniqueFormatToIndexMap(sortedInfos []Info) map[indexV2FormatInfo]byte {
+func buildUniqueFormatToIndexMap(sortedInfos []*Info) map[indexV2FormatInfo]byte {
 	result := map[indexV2FormatInfo]byte{}
 
 	for _, v := range sortedInfos {
@@ -396,7 +396,7 @@ func buildUniqueFormatToIndexMap(sortedInfos []Info) map[indexV2FormatInfo]byte
 }
 
 // buildPackIDToIndexMap builds a map of unique blob IDs to their numeric identifiers.
-func buildPackIDToIndexMap(sortedInfos []Info) map[blob.ID]int {
+func buildPackIDToIndexMap(sortedInfos []*Info) map[blob.ID]int {
 	result := map[blob.ID]int{}
 
 	for _, v := range sortedInfos {
@@ -410,7 +410,7 @@ func buildPackIDToIndexMap(sortedInfos []Info) map[blob.ID]int {
 }
 
 // maxContentLengths computes max content lengths in the builder.
-func maxContentLengths(sortedInfos []Info) (maxPackedLength, maxOriginalLength, maxPackOffset uint32) {
+func maxContentLengths(sortedInfos []*Info) (maxPackedLength, maxOriginalLength, maxPackOffset uint32) {
 	for _, v := range sortedInfos {
 		if l := v.PackedLength; l > maxPackedLength {
 			maxPackedLength = l
@@ -428,7 +428,7 @@ func maxContentLengths(sortedInfos []Info) (maxPackedLength, maxOriginalLength,
 	return
 }
 
-func newIndexBuilderV2(sortedInfos []Info) (*indexBuilderV2, error) {
+func newIndexBuilderV2(sortedInfos []*Info) (*indexBuilderV2, error) {
 	entrySize := v2EntryOffsetFormatID
 
 	// compute a map of unique formats to their indexes.
@@ -487,9 +487,7 @@ func newIndexBuilderV2(sortedInfos []Info) (*indexBuilderV2, error) {
 }
 
 // buildV2 writes the pack index to the provided output.
-func (b Builder) buildV2(output io.Writer) error {
-	sortedInfos := b.sortedContents()
-
+func buildV2(sortedInfos []*Info, output io.Writer) error {
 	b2, err := newIndexBuilderV2(sortedInfos)
 	if err != nil {
 		return err
@@ -501,7 +499,7 @@ func (b Builder) buildV2(output io.Writer) error {
 	extraData := b2.prepareExtraData(sortedInfos)
 
 	if b2.keyLength <= 1 {
-		return errors.Errorf("invalid key length: %v for %v", b2.keyLength, len(b))
+		return errors.Errorf("invalid key length: %v for %v", b2.keyLength, len(sortedInfos))
 	}
 
 	// write header
@@ -558,7 +556,7 @@ func (b Builder) buildV2(output io.Writer) error {
 	return errors.Wrap(w.Flush(), "error flushing index")
 }
 
-func (b *indexBuilderV2) prepareExtraData(sortedInfos []Info) []byte {
+func (b *indexBuilderV2) prepareExtraData(sortedInfos []*Info) []byte {
 	var extraData []byte
 
 	for _, it := range sortedInfos {
@@ -581,7 +579,7 @@ func (b *indexBuilderV2) prepareExtraData(sortedInfos []Info) []byte {
 	return extraData
 }
 
-func (b *indexBuilderV2) writeIndexEntry(w io.Writer, it Info) error {
+func (b *indexBuilderV2) writeIndexEntry(w io.Writer, it *Info) error {
 	var hashBuf [maxContentIDSize]byte
 
 	k := contentIDToBytes(hashBuf[:0], it.ContentID)
@@ -624,7 +622,7 @@ func (b *indexBuilderV2) writeFormatInfoEntry(w io.Writer, f indexV2FormatInfo)
 	return errors.Wrap(err, "error writing format info entry")
 }
 
-func (b *indexBuilderV2) writeIndexValueEntry(w io.Writer, it Info) error {
+func (b *indexBuilderV2) writeIndexValueEntry(w io.Writer, it *Info) error {
 	var buf [v2EntryMaxLength]byte
 
 	//    0-3: timestamp bits 0..31 (relative to base time)
diff --git a/repo/content/index/merged.go b/repo/content/index/merged.go
index 1bd939de..603c90e4 100644
--- a/repo/content/index/merged.go
+++ b/repo/content/index/merged.go
@@ -33,7 +33,7 @@ func (m Merged) Close() error {
 	return errors.Wrap(err, "closing index shards")
 }
 
-func contentInfoGreaterThanStruct(a, b Info) bool {
+func contentInfoGreaterThanStruct(a, b *Info) bool {
 	if l, r := a.TimestampSeconds, b.TimestampSeconds; l != r {
 		// different timestamps, higher one wins
 		return l > r
@@ -66,7 +66,7 @@ func (m Merged) GetInfo(id ID, result *Info) (bool, error) {
 			continue
 		}
 
-		if !found || contentInfoGreaterThanStruct(tmp, *result) {
+		if !found || contentInfoGreaterThanStruct(&tmp, result) {
 			*result = tmp
 			found = true
 		}
@@ -88,7 +88,7 @@ func (h nextInfoHeap) Less(i, j int) bool {
 		return a.less(b)
 	}
 
-	return !contentInfoGreaterThanStruct(h[i].it, h[j].it)
+	return !contentInfoGreaterThanStruct(&h[i].it, &h[j].it)
 }
 
 func (h nextInfoHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] }
@@ -167,7 +167,7 @@ func (m Merged) Iterate(r IDRange, cb func(i Info) error) error {
 
 			pendingItem = minNextInfo.it
 			havePendingItem = true
-		} else if contentInfoGreaterThanStruct(minNextInfo.it, pendingItem) {
+		} else if contentInfoGreaterThanStruct(&minNextInfo.it, &pendingItem) {
 			pendingItem = minNextInfo.it
 		}
 
diff --git a/repo/content/index/one_use_index_builder.go b/repo/content/index/one_use_index_builder.go
new file mode 100644
index 00000000..56cdee1c
--- /dev/null
+++ b/repo/content/index/one_use_index_builder.go
@@ -0,0 +1,144 @@
+package index
+
+import (
+	"crypto/rand"
+	"hash/fnv"
+	"io"
+
+	"github.com/pkg/errors"
+
+	"github.com/petar/GoLLRB/llrb"
+
+	"github.com/kopia/kopia/internal/gather"
+)
+
+// Less compares with another *Info by their ContentID and return true if the current one is smaller.
+func (i *Info) Less(other llrb.Item) bool {
+	return i.ContentID.less(other.(*Info).ContentID) //nolint:forcetypeassert
+}
+
+// OneUseBuilder prepares and writes content index for epoch index compaction.
+type OneUseBuilder struct {
+	indexStore *llrb.LLRB
+}
+
+// NewOneUseBuilder create a new instance of OneUseBuilder.
+func NewOneUseBuilder() *OneUseBuilder {
+	return &OneUseBuilder{
+		indexStore: llrb.New(),
+	}
+}
+
+// Add adds a new entry to the builder or conditionally replaces it if the timestamp is greater.
+func (b *OneUseBuilder) Add(i Info) {
+	found := b.indexStore.Get(&i)
+	if found == nil || contentInfoGreaterThanStruct(&i, found.(*Info)) { //nolint:forcetypeassert
+		_ = b.indexStore.ReplaceOrInsert(&i)
+	}
+}
+
+// Length returns the number of indexes in the current builder.
+func (b *OneUseBuilder) Length() int {
+	return b.indexStore.Len()
+}
+
+func (b *OneUseBuilder) sortedContents() []*Info {
+	result := []*Info{}
+
+	for b.indexStore.Len() > 0 {
+		item := b.indexStore.DeleteMin()
+		result = append(result, item.(*Info)) //nolint:forcetypeassert
+	}
+
+	return result
+}
+
+func (b *OneUseBuilder) shard(maxShardSize int) [][]*Info {
+	numShards := (b.Length() + maxShardSize - 1) / maxShardSize
+	if numShards <= 1 {
+		if b.Length() == 0 {
+			return [][]*Info{}
+		}
+
+		return [][]*Info{b.sortedContents()}
+	}
+
+	result := make([][]*Info, numShards)
+
+	for b.indexStore.Len() > 0 {
+		item := b.indexStore.DeleteMin()
+
+		h := fnv.New32a()
+		io.WriteString(h, item.(*Info).ContentID.String()) //nolint:errcheck,forcetypeassert
+
+		shard := h.Sum32() % uint32(numShards) //nolint:gosec
+
+		result[shard] = append(result[shard], item.(*Info)) //nolint:forcetypeassert
+	}
+
+	var nonEmpty [][]*Info
+
+	for _, r := range result {
+		if len(r) > 0 {
+			nonEmpty = append(nonEmpty, r)
+		}
+	}
+
+	return nonEmpty
+}
+
+// BuildStable writes the pack index to the provided output.
+func (b *OneUseBuilder) BuildStable(output io.Writer, version int) error {
+	return buildSortedContents(b.sortedContents(), output, version)
+}
+
+// BuildShards builds the set of index shards ensuring no more than the provided number of contents are in each index.
+// Returns shard bytes and function to clean up after the shards have been written.
+func (b *OneUseBuilder) BuildShards(indexVersion int, stable bool, shardSize int) ([]gather.Bytes, func(), error) {
+	if shardSize == 0 {
+		return nil, nil, errors.Errorf("invalid shard size")
+	}
+
+	var (
+		shardedBuilders = b.shard(shardSize)
+		dataShardsBuf   []*gather.WriteBuffer
+		dataShards      []gather.Bytes
+		randomSuffix    [32]byte
+	)
+
+	closeShards := func() {
+		for _, ds := range dataShardsBuf {
+			ds.Close()
+		}
+	}
+
+	for _, s := range shardedBuilders {
+		buf := gather.NewWriteBuffer()
+
+		dataShardsBuf = append(dataShardsBuf, buf)
+
+		if err := buildSortedContents(s, buf, indexVersion); err != nil {
+			closeShards()
+
+			return nil, nil, errors.Wrap(err, "error building index shard")
+		}
+
+		if !stable {
+			if _, err := rand.Read(randomSuffix[:]); err != nil {
+				closeShards()
+
+				return nil, nil, errors.Wrap(err, "error getting random bytes for suffix")
+			}
+
+			if _, err := buf.Write(randomSuffix[:]); err != nil {
+				closeShards()
+
+				return nil, nil, errors.Wrap(err, "error writing extra random suffix to ensure indexes are always globally unique")
+			}
+		}
+
+		dataShards = append(dataShards, buf.Bytes())
+	}
+
+	return dataShards, closeShards, nil
+}
diff --git a/repo/content/indexblob/index_blob_manager_v0.go b/repo/content/indexblob/index_blob_manager_v0.go
index 887d1b72..cb46024f 100644
--- a/repo/content/indexblob/index_blob_manager_v0.go
+++ b/repo/content/indexblob/index_blob_manager_v0.go
@@ -550,7 +550,7 @@ func (m *ManagerV0) dropContentsFromBuilder(bld index.Builder, opt CompactOption
 	}
 }
 
-func addIndexBlobsToBuilder(ctx context.Context, enc *EncryptionManager, bld index.Builder, indexBlobID blob.ID) error {
+func addIndexBlobsToBuilder(ctx context.Context, enc *EncryptionManager, bld index.BuilderCreator, indexBlobID blob.ID) error {
 	var data gather.WriteBuffer
 	defer data.Close()
 
diff --git a/repo/content/indexblob/index_blob_manager_v1.go b/repo/content/indexblob/index_blob_manager_v1.go
index 04df58d0..edf35580 100644
--- a/repo/content/indexblob/index_blob_manager_v1.go
+++ b/repo/content/indexblob/index_blob_manager_v1.go
@@ -68,7 +68,7 @@ func (m *ManagerV1) Compact(ctx context.Context, opt CompactOptions) error {
 
 // CompactEpoch compacts the provided index blobs and writes a new set of blobs.
 func (m *ManagerV1) CompactEpoch(ctx context.Context, blobIDs []blob.ID, outputPrefix blob.ID) error {
-	tmpbld := make(index.Builder)
+	tmpbld := index.NewOneUseBuilder()
 
 	for _, indexBlob := range blobIDs {
 		if err := addIndexBlobsToBuilder(ctx, m.enc, tmpbld, indexBlob); err != nil {
diff --git a/repo/manifest/committed_manifest_manager.go b/repo/manifest/committed_manifest_manager.go
index d955ce72..109fb6ea 100644
--- a/repo/manifest/committed_manifest_manager.go
+++ b/repo/manifest/committed_manifest_manager.go
@@ -32,7 +32,7 @@ type committedManifestManager struct {
 	// +checklocks:cmmu
 	committedEntries map[ID]*manifestEntry
 	// +checklocks:cmmu
-	committedContentIDs map[content.ID]bool
+	committedContentIDs map[content.ID]struct{}
 
 	// autoCompactionThreshold controls the threshold after which the manager auto-compacts
 	// manifest contents
@@ -79,7 +79,7 @@ func (m *committedManifestManager) findCommittedEntries(ctx context.Context, lab
 	return findEntriesMatchingLabels(m.committedEntries, labels), nil
 }
 
-func (m *committedManifestManager) commitEntries(ctx context.Context, entries map[ID]*manifestEntry) (map[content.ID]bool, error) {
+func (m *committedManifestManager) commitEntries(ctx context.Context, entries map[ID]*manifestEntry) (map[content.ID]struct{}, error) {
 	if len(entries) == 0 {
 		return nil, nil
 	}
@@ -98,7 +98,7 @@ func (m *committedManifestManager) commitEntries(ctx context.Context, entries ma
 // the lock via commitEntries()) and to compact existing committed entries during compaction
 // where the lock is already being held.
 // +checklocks:m.cmmu
-func (m *committedManifestManager) writeEntriesLocked(ctx context.Context, entries map[ID]*manifestEntry) (map[content.ID]bool, error) {
+func (m *committedManifestManager) writeEntriesLocked(ctx context.Context, entries map[ID]*manifestEntry) (map[content.ID]struct{}, error) {
 	if len(entries) == 0 {
 		return nil, nil
 	}
@@ -124,12 +124,11 @@ func (m *committedManifestManager) writeEntriesLocked(ctx context.Context, entri
 
 	for _, e := range entries {
 		m.committedEntries[e.ID] = e
-		delete(entries, e.ID)
 	}
 
-	m.committedContentIDs[contentID] = true
+	m.committedContentIDs[contentID] = struct{}{}
 
-	return map[content.ID]bool{contentID: true}, nil
+	return map[content.ID]struct{}{contentID: {}}, nil
 }
 
 // +checklocks:m.cmmu
@@ -192,10 +191,10 @@ func (m *committedManifestManager) loadCommittedContentsLocked(ctx context.Conte
 // +checklocks:m.cmmu
 func (m *committedManifestManager) loadManifestContentsLocked(manifests map[content.ID]manifest) {
 	m.committedEntries = map[ID]*manifestEntry{}
-	m.committedContentIDs = map[content.ID]bool{}
+	m.committedContentIDs = map[content.ID]struct{}{}
 
 	for contentID := range manifests {
-		m.committedContentIDs[contentID] = true
+		m.committedContentIDs[contentID] = struct{}{}
 	}
 
 	for _, man := range manifests {
@@ -257,19 +256,14 @@ func (m *committedManifestManager) compactLocked(ctx context.Context) error {
 	m.b.DisableIndexFlush(ctx)
 	defer m.b.EnableIndexFlush(ctx)
 
-	tmp := map[ID]*manifestEntry{}
-	for k, v := range m.committedEntries {
-		tmp[k] = v
-	}
-
-	written, err := m.writeEntriesLocked(ctx, tmp)
+	written, err := m.writeEntriesLocked(ctx, m.committedEntries)
 	if err != nil {
 		return err
 	}
 
 	// add the newly-created content to the list, could be duplicate
 	for b := range m.committedContentIDs {
-		if written[b] {
+		if _, ok := written[b]; ok {
 			// do not delete content that was just written.
 			continue
 		}
@@ -374,7 +368,7 @@ func newCommittedManager(b contentManager, autoCompactionThreshold int) *committ
 		b:                       b,
 		debugID:                 debugID,
 		committedEntries:        map[ID]*manifestEntry{},
-		committedContentIDs:     map[content.ID]bool{},
+		committedContentIDs:     map[content.ID]struct{}{},
 		autoCompactionThreshold: autoCompactionThreshold,
 	}
 }
diff --git a/repo/manifest/manifest_manager.go b/repo/manifest/manifest_manager.go
index cb53935f..a90c1dbb 100644
--- a/repo/manifest/manifest_manager.go
+++ b/repo/manifest/manifest_manager.go
@@ -215,6 +215,9 @@ func (m *Manager) Flush(ctx context.Context) error {
 	defer m.mu.Unlock()
 
 	_, err := m.committed.commitEntries(ctx, m.pendingEntries)
+	if err == nil {
+		m.pendingEntries = map[ID]*manifestEntry{}
+	}
 
 	return err
 }


Test patch:
diff --git a/internal/server/api_notification_profile_test.go b/internal/server/api_notification_profile_test.go
new file mode 100644
index 00000000..e35c025d
--- /dev/null
+++ b/internal/server/api_notification_profile_test.go
@@ -0,0 +1,87 @@
+package server_test
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/require"
+
+	"github.com/kopia/kopia/internal/apiclient"
+	"github.com/kopia/kopia/internal/repotesting"
+	"github.com/kopia/kopia/internal/serverapi"
+	"github.com/kopia/kopia/internal/servertesting"
+	"github.com/kopia/kopia/notification/notifyprofile"
+	"github.com/kopia/kopia/notification/sender"
+	"github.com/kopia/kopia/notification/sender/testsender"
+)
+
+func TestNotificationProfile(t *testing.T) {
+	ctx, env := repotesting.NewEnvironment(t, repotesting.FormatNotImportant)
+	srvInfo := servertesting.StartServer(t, env, false)
+
+	cli, err := apiclient.NewKopiaAPIClient(apiclient.Options{
+		BaseURL:                             srvInfo.BaseURL,
+		TrustedServerCertificateFingerprint: srvInfo.TrustedServerCertificateFingerprint,
+		Username:                            servertesting.TestUIUsername,
+		Password:                            servertesting.TestUIPassword,
+	})
+
+	require.NoError(t, err)
+
+	require.NoError(t, cli.FetchCSRFTokenForTesting(ctx))
+
+	var profiles []notifyprofile.Config
+
+	require.NoError(t, cli.Get(ctx, "notificationProfiles", nil, &profiles))
+	require.Empty(t, profiles)
+
+	// define new profile
+	require.NoError(t, cli.Post(ctx, "notificationProfiles", &notifyprofile.Config{
+		ProfileName: "profile1",
+		MethodConfig: sender.MethodConfig{
+			Type: "testsender",
+			Config: testsender.Options{
+				Format: "txt",
+			},
+		},
+		MinSeverity: 3,
+	}, &serverapi.Empty{}))
+
+	// define invalid profile
+	require.ErrorContains(t, cli.Post(ctx, "notificationProfiles", &notifyprofile.Config{
+		ProfileName: "profile2",
+		MethodConfig: sender.MethodConfig{
+			Type: "no-such-type",
+			Config: testsender.Options{
+				Format: "txt",
+			},
+		},
+		MinSeverity: 3,
+	}, &serverapi.Empty{}), "malformed request body")
+
+	var cfg notifyprofile.Config
+
+	// get profile and verify
+	require.NoError(t, cli.Get(ctx, "notificationProfiles/profile1", nil, &cfg))
+	require.Equal(t, "profile1", cfg.ProfileName)
+	require.Equal(t, sender.Method("testsender"), cfg.MethodConfig.Type)
+
+	opt, ok := cfg.MethodConfig.Config.(map[string]any)
+	require.True(t, ok)
+	require.Equal(t, "txt", opt["format"])
+
+	// get non-existent profile
+	require.ErrorContains(t, cli.Get(ctx, "notificationProfiles/profile2", nil, &cfg), "profile not found")
+
+	// list profiles
+	require.NoError(t, cli.Get(ctx, "notificationProfiles", nil, &profiles))
+	require.Len(t, profiles, 1)
+	require.Equal(t, "profile1", profiles[0].ProfileName)
+
+	// delete the profile, ensure idempotent
+	require.NoError(t, cli.Delete(ctx, "notificationProfiles/profile1", nil, nil, &serverapi.Empty{}))
+	require.NoError(t, cli.Delete(ctx, "notificationProfiles/profile1", nil, nil, &serverapi.Empty{}))
+
+	// verify it's gone
+	require.NoError(t, cli.Get(ctx, "notificationProfiles", nil, &profiles))
+	require.Empty(t, profiles)
+}
diff --git a/repo/content/index/packindex_test.go b/repo/content/index/packindex_test.go
index b50b59d2..86644bfc 100644
--- a/repo/content/index/packindex_test.go
+++ b/repo/content/index/packindex_test.go
@@ -144,31 +144,34 @@ func testPackIndex(t *testing.T, version int) {
 	b1 := make(Builder)
 	b2 := make(Builder)
 	b3 := make(Builder)
+	b4 := NewOneUseBuilder()
 
 	for _, info := range infos {
 		infoMap[info.ContentID] = info
 		b1.Add(info)
 		b2.Add(info)
 		b3.Add(info)
+		b4.Add(info)
 	}
 
-	var buf1, buf2, buf3 bytes.Buffer
+	var buf1, buf2, buf3, buf4 bytes.Buffer
 
-	if err := b1.Build(&buf1, version); err != nil {
-		t.Fatalf("unable to build: %v", err)
-	}
+	err := b1.Build(&buf1, version)
+	require.NoError(t, err)
 
-	if err := b2.Build(&buf2, version); err != nil {
-		t.Fatalf("unable to build: %v", err)
-	}
+	err = b2.Build(&buf2, version)
+	require.NoError(t, err)
 
-	if err := b3.BuildStable(&buf3, version); err != nil {
-		t.Fatalf("unable to build: %v", err)
-	}
+	err = b3.BuildStable(&buf3, version)
+	require.NoError(t, err)
+
+	err = b4.BuildStable(&buf4, version)
+	require.NoError(t, err)
 
 	data1 := buf1.Bytes()
 	data2 := buf2.Bytes()
 	data3 := buf3.Bytes()
+	data4 := buf4.Bytes()
 
 	// each build produces exactly identical prefix except for the trailing random bytes.
 	data1Prefix := data1[0 : len(data1)-randomSuffixSize]
@@ -176,13 +179,22 @@ func testPackIndex(t *testing.T, version int) {
 
 	require.Equal(t, data1Prefix, data2Prefix)
 	require.Equal(t, data2Prefix, data3)
+	require.Equal(t, data2Prefix, data4)
 	require.NotEqual(t, data1, data2)
+	require.Equal(t, data3, data4)
 
 	t.Run("FuzzTest", func(t *testing.T) {
 		fuzzTestIndexOpen(data1)
 	})
 
-	ndx, err := Open(data1, nil, func() int { return fakeEncryptionOverhead })
+	verifyPackedIndexes(t, infos, infoMap, version, data1)
+	verifyPackedIndexes(t, infos, infoMap, version, data4)
+}
+
+func verifyPackedIndexes(t *testing.T, infos []Info, infoMap map[ID]Info, version int, packed []byte) {
+	t.Helper()
+
+	ndx, err := Open(packed, nil, func() int { return fakeEncryptionOverhead })
 	if err != nil {
 		t.Fatalf("can't open index: %v", err)
 	}
@@ -276,7 +288,7 @@ func TestPackIndexPerContentLimits(t *testing.T) {
 		var result bytes.Buffer
 
 		if tc.errMsg == "" {
-			require.NoError(t, b.buildV2(&result))
+			require.NoError(t, buildV2(b.sortedContents(), &result))
 
 			pi, err := Open(result.Bytes(), nil, func() int { return fakeEncryptionOverhead })
 			require.NoError(t, err)
@@ -289,7 +301,7 @@ func TestPackIndexPerContentLimits(t *testing.T) {
 
 			require.Equal(t, got, tc.info)
 		} else {
-			err := b.buildV2(&result)
+			err := buildV2(b.sortedContents(), &result)
 			require.Error(t, err)
 			require.Contains(t, err.Error(), tc.errMsg)
 		}
@@ -366,6 +378,76 @@ func TestSortedContents2(t *testing.T) {
 	}
 }
 
+func TestSortedContents3(t *testing.T) {
+	b := NewOneUseBuilder()
+
+	for i := range 100 {
+		v := deterministicContentID(t, "", i)
+
+		b.Add(Info{
+			ContentID: v,
+		})
+	}
+
+	got := b.sortedContents()
+
+	var last ID
+	for _, info := range got {
+		if info.ContentID.less(last) {
+			t.Fatalf("not sorted %v (was %v)!", info.ContentID, last)
+		}
+
+		last = info.ContentID
+	}
+}
+
+func TestSortedContents4(t *testing.T) {
+	b := NewOneUseBuilder()
+
+	b.Add(Info{
+		ContentID: mustParseID(t, "0123"),
+	})
+	b.Add(Info{
+		ContentID: mustParseID(t, "1023"),
+	})
+	b.Add(Info{
+		ContentID: mustParseID(t, "0f23"),
+	})
+	b.Add(Info{
+		ContentID: mustParseID(t, "f023"),
+	})
+	b.Add(Info{
+		ContentID: mustParseID(t, "g0123"),
+	})
+	b.Add(Info{
+		ContentID: mustParseID(t, "g1023"),
+	})
+	b.Add(Info{
+		ContentID: mustParseID(t, "i0123"),
+	})
+	b.Add(Info{
+		ContentID: mustParseID(t, "i1023"),
+	})
+	b.Add(Info{
+		ContentID: mustParseID(t, "h0123"),
+	})
+	b.Add(Info{
+		ContentID: mustParseID(t, "h1023"),
+	})
+
+	got := b.sortedContents()
+
+	var last ID
+
+	for _, info := range got {
+		if info.ContentID.less(last) {
+			t.Fatalf("not sorted %v (was %v)!", info.ContentID, last)
+		}
+
+		last = info.ContentID
+	}
+}
+
 func TestPackIndexV2TooManyUniqueFormats(t *testing.T) {
 	b := Builder{}
 
@@ -380,7 +462,7 @@ func TestPackIndexV2TooManyUniqueFormats(t *testing.T) {
 		})
 	}
 
-	require.NoError(t, b.buildV2(io.Discard))
+	require.NoError(t, buildV2(b.sortedContents(), io.Discard))
 
 	// add one more to push it over the edge
 	b.Add(Info{
@@ -389,7 +471,7 @@ func TestPackIndexV2TooManyUniqueFormats(t *testing.T) {
 		CompressionHeaderID: compression.HeaderID(5000),
 	})
 
-	err := b.buildV2(io.Discard)
+	err := buildV2(b.sortedContents(), io.Discard)
 	require.Error(t, err)
 	require.Equal(t, "unsupported - too many unique formats 256 (max 255)", err.Error())
 }
@@ -518,6 +600,83 @@ func verifyAllShardedIDs(t *testing.T, sharded []Builder, numTotal, numShards in
 	return lens
 }
 
+func TestShard1(t *testing.T) {
+	// generate 10000 IDs in random order
+	ids := make([]int, 10000)
+	for i := range ids {
+		ids[i] = i
+	}
+
+	rand.Shuffle(len(ids), func(i, j int) {
+		ids[i], ids[j] = ids[j], ids[i]
+	})
+
+	cases := []struct {
+		shardSize int
+		numShards int
+		shardLens []int
+	}{
+		{100000, 1, nil},
+		{100, 100, nil},
+		{500, 20, []int{460, 472, 473, 477, 479, 483, 486, 492, 498, 499, 501, 503, 504, 505, 511, 519, 524, 528, 542, 544}},
+		{1000, 10, []int{945, 964, 988, 988, 993, 1002, 1014, 1017, 1021, 1068}},
+		{2000, 5, []int{1952, 1995, 2005, 2013, 2035}},
+	}
+
+	for _, tc := range cases {
+		b := NewOneUseBuilder()
+
+		// add ID to the builder
+		for _, id := range ids {
+			b.Add(Info{
+				ContentID: deterministicContentID(t, "", id),
+			})
+		}
+
+		length := b.Length()
+		shards := b.shard(tc.shardSize)
+
+		// verify number of shards
+		lens := verifyAllShardedIDsList(t, shards, length, tc.numShards)
+
+		require.Zero(t, b.Length())
+
+		// sharding will always produce stable results, verify sorted shard lengths here
+		if tc.shardLens != nil {
+			require.ElementsMatch(t, tc.shardLens, lens)
+		}
+	}
+}
+
+func verifyAllShardedIDsList(t *testing.T, sharded [][]*Info, numTotal, numShards int) []int {
+	t.Helper()
+
+	require.Len(t, sharded, numShards)
+
+	m := map[ID]bool{}
+	for i := range numTotal {
+		m[deterministicContentID(t, "", i)] = true
+	}
+
+	cnt := 0
+
+	var lens []int
+
+	for _, s := range sharded {
+		cnt += len(s)
+		lens = append(lens, len(s))
+
+		for _, v := range s {
+			delete(m, v.ContentID)
+		}
+	}
+
+	require.Equal(t, numTotal, cnt, "invalid total number of sharded elements")
+	require.Empty(t, m)
+
+	return lens
+}
+
 func withOriginalLength(is Info, originalLength uint32) Info {
 	// clone and override original length
 	is.OriginalLength = originalLength
diff --git a/repo/manifest/manifest_manager_test.go b/repo/manifest/manifest_manager_test.go
index 9c567d88..c60fa24c 100644
--- a/repo/manifest/manifest_manager_test.go
+++ b/repo/manifest/manifest_manager_test.go
@@ -3,6 +3,7 @@
 import (
 	"context"
 	"encoding/json"
+	"fmt"
 	"reflect"
 	"sort"
 	"strings"
@@ -310,8 +311,8 @@ type contentManagerOpts struct {
 	readOnly bool
 }
 
-func newContentManagerForTesting(ctx context.Context, t *testing.T, data blobtesting.DataMap, opts contentManagerOpts) contentManager {
-	t.Helper()
+func newContentManagerForTesting(ctx context.Context, tb testing.TB, data blobtesting.DataMap, opts contentManagerOpts) contentManager {
+	tb.Helper()
 
 	st := blobtesting.NewMapStorage(data, nil, nil)
 
@@ -328,12 +329,12 @@ func newContentManagerForTesting(ctx context.Context, t *testing.T, data blobtes
 		},
 	}, nil)
 
-	require.NoError(t, err)
+	require.NoError(tb, err)
 
 	bm, err := content.NewManagerForTesting(ctx, st, fop, nil, nil)
-	require.NoError(t, err)
+	require.NoError(tb, err)
 
-	t.Cleanup(func() { bm.CloseShared(ctx) })
+	tb.Cleanup(func() { bm.CloseShared(ctx) })
 
 	return bm
 }
@@ -488,3 +489,50 @@ func TestManifestAutoCompactionWithReadOnly(t *testing.T) {
 	_, err = mgr.Find(ctx, map[string]string{"color": "red"})
 	require.NoError(t, err, "forcing reload of manifest manager")
 }
+
+func BenchmarkLargeCompaction(b *testing.B) {
+	item1 := map[string]int{"foo": 1, "bar": 2}
+	labels1 := map[string]string{"type": "item", "color": "red"}
+
+	table := []int{10000, 100000, 1000000}
+
+	for _, numItems := range table {
+		b.Run(fmt.Sprintf("%dItems", numItems), func(b *testing.B) {
+			for range b.N {
+				b.StopTimer()
+				// Use default context to avoid lots of log output during benchmark.
+				ctx := context.Background()
+				data := blobtesting.DataMap{}
+
+				bm := newContentManagerForTesting(ctx, b, data, contentManagerOpts{})
+
+				mgr, err := NewManager(
+					ctx,
+					bm,
+					ManagerOptions{AutoCompactionThreshold: 2},
+					nil,
+				)
+				require.NoError(b, err, "getting initial manifest manager")
+
+				for range numItems - 1 {
+					_, err = mgr.Put(ctx, labels1, item1)
+					require.NoError(b, err, "adding item to manifest manager")
+				}
+
+				require.NoError(b, mgr.Flush(ctx))
+				require.NoError(b, mgr.b.Flush(ctx))
+
+				_, err = mgr.Put(ctx, labels1, item1)
+				require.NoError(b, err, "adding item to manifest manager")
+
+				require.NoError(b, mgr.Flush(ctx))
+				require.NoError(b, mgr.b.Flush(ctx))
+
+				b.StartTimer()
+
+				err = mgr.Compact(ctx)
+				require.NoError(b, err, "forcing reload of manifest manager")
+			}
+		})
+	}
+}
diff --git a/tests/end_to_end_test/server_start_test.go b/tests/end_to_end_test/server_start_test.go
index c22786da..ca24410a 100644
--- a/tests/end_to_end_test/server_start_test.go
+++ b/tests/end_to_end_test/server_start_test.go
@@ -30,6 +30,8 @@
 	"github.com/kopia/kopia/tests/testenv"
 )
 
+const defaultServerControlUsername = "server-control"
+
 func TestServerStart(t *testing.T) {
 	ctx := testlogging.Context(t)
 
@@ -74,7 +76,7 @@ func TestServerStart(t *testing.T) {
 
 	controlClient, err := apiclient.NewKopiaAPIClient(apiclient.Options{
 		BaseURL:                             sp.BaseURL,
-		Username:                            "server-control",
+		Username:                            defaultServerControlUsername,
 		Password:                            sp.ServerControlPassword,
 		TrustedServerCertificateFingerprint: sp.SHA256Fingerprint,
 		LogRequests:                         true,
@@ -219,7 +221,7 @@ func TestServerStartAsyncRepoConnect(t *testing.T) {
 
 	controlClient, err := apiclient.NewKopiaAPIClient(apiclient.Options{
 		BaseURL:                             sp.BaseURL,
-		Username:                            "server-control",
+		Username:                            defaultServerControlUsername,
 		Password:                            sp.ServerControlPassword,
 		TrustedServerCertificateFingerprint: sp.SHA256Fingerprint,
 		LogRequests:                         true,
@@ -298,7 +300,7 @@ func TestServerCreateAndConnectViaAPI(t *testing.T) {
 
 	controlClient, err := apiclient.NewKopiaAPIClient(apiclient.Options{
 		BaseURL:                             sp.BaseURL,
-		Username:                            "server-control",
+		Username:                            defaultServerControlUsername,
 		Password:                            sp.ServerControlPassword,
 		TrustedServerCertificateFingerprint: sp.SHA256Fingerprint,
 		LogRequests:                         true,
@@ -377,7 +379,7 @@ func TestConnectToExistingRepositoryViaAPI(t *testing.T) {
 
 	controlClient, err := apiclient.NewKopiaAPIClient(apiclient.Options{
 		BaseURL:                             sp.BaseURL,
-		Username:                            "server-control",
+		Username:                            defaultServerControlUsername,
 		Password:                            sp.ServerControlPassword,
 		TrustedServerCertificateFingerprint: sp.SHA256Fingerprint,
 	})
@@ -530,8 +532,15 @@ func TestServerStartInsecure(t *testing.T) {
 	waitUntilServerStarted(ctx, t, cli)
 
 	// server fails to start with --without-password when `--insecure` is not specified
-	e.RunAndExpectFailure(t, "server", "start", "--ui", "--address=localhost:0", "--without-password")                        // without TLS
-	e.RunAndExpectFailure(t, "server", "start", "--ui", "--address=localhost:0", "--without-password", "--tls-generate-cert") // with TLS
+	e.RunAndExpectFailure(t, "server", "start", "--ui", "--address=localhost:0", "--without-password") // without TLS
+
+	// with TLS
+	e.RunAndExpectFailure(t, "server", "start", "--ui",
+		"--address=localhost:0",
+		"--without-password",
+		"--tls-generate-cert",
+		"--tls-generate-rsa-key-size=2048", // use shorter key size to speed up generation,
+	)
 
 	// server fails to start when TLS is not configured and `--insecure` is not specified
 	e.RunAndExpectFailure(t, "server", "start", "--ui", "--address=localhost:0")