Skip to content

Commit e0fe499

Browse files
worstellampagent
andcommitted
feat(git): add periodic full repack to shrink mirrors and snapshots
Mirrors are maintained by incremental fetches, each of which packs only its own objects with a narrow delta window. Over time this accumulates suboptimal, cross-pack-redundant deltas. The existing geometric repack consolidates packs but reuses those deltas, so it never recovers the redundancy. Add a separate full repack (git repack -a -d -f --window --depth) that re-selects deltas across all objects, materially shrinking the mirror and the snapshots derived from it, in exchange for significant one-time CPU. It runs on its own slow cadence (full-repack-interval) alongside the frequent geometric repack, and is disabled by default. Window, depth, and timeout are configurable. Co-authored-by: Amp <amp@ampcode.com> Amp-Thread-ID: https://ampcode.com/threads/T-019eae3d-a2fd-70ca-80fe-a7536ec6748c
1 parent 82f252d commit e0fe499

7 files changed

Lines changed: 140 additions & 31 deletions

File tree

README.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,9 @@ cachew git restore https://github.com/org/repo ./repo
2626

2727
```hcl
2828
git {
29-
snapshot-interval = "1h"
30-
repack-interval = "1h"
29+
snapshot-interval = "1h"
30+
repack-interval = "1h"
31+
full-repack-interval = "24h"
3132
}
3233
```
3334

@@ -275,8 +276,9 @@ github-app {
275276
git-clone {}
276277
277278
git {
278-
snapshot-interval = "1h"
279-
repack-interval = "1h"
279+
snapshot-interval = "1h"
280+
repack-interval = "1h"
281+
full-repack-interval = "24h"
280282
}
281283
282284
github-releases {

internal/gitclone/manager.go

Lines changed: 57 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -65,8 +65,19 @@ type Config struct {
6565
LsRemoteTimeout time.Duration `hcl:"ls-remote-timeout,optional" help:"Upper bound for 'git ls-remote' so a slow upstream cannot block the request path indefinitely." default:"1m"`
6666
RepackTimeout time.Duration `hcl:"repack-timeout,optional" help:"Upper bound for 'git repack' so a slow repack on a large repository cannot block the scheduler queue indefinitely." default:"10m"`
6767
RepackThreads int `hcl:"repack-threads,optional" help:"Threads for git repack operations. Limits memory since windowMemory and deltaCacheSize are per-thread. 0 = pack-threads." default:"4"`
68+
69+
FullRepackTimeout time.Duration `hcl:"full-repack-timeout,optional" help:"Upper bound for the full (delta-recomputing) repack, which is far slower than the geometric repack on large repositories. 0 falls back to repack-timeout." default:"1h"`
6870
}
6971

72+
// Delta search window and chain depth for the full repack. A wider window finds
73+
// tighter deltas (smaller packs) at the cost of more CPU; these values were
74+
// chosen empirically. Not configurable: tuning them changes the size/CPU
75+
// tradeoff in ways that can't be reasoned about without re-benchmarking.
76+
const (
77+
fullRepackWindow = 100
78+
fullRepackDepth = 50
79+
)
80+
7081
// CredentialProvider provides credentials for git operations.
7182
type CredentialProvider interface {
7283
GetTokenForURL(ctx context.Context, url string) (string, error)
@@ -807,8 +818,46 @@ func (r *Repository) GetUpstreamRefs(ctx context.Context) (map[string]string, er
807818
func (r *Repository) Repack(ctx context.Context) error {
808819
logger := logging.FromContext(ctx)
809820
logger.InfoContext(ctx, "Geometric repack started", "upstream", r.upstreamURL)
821+
if err := r.runRepack(ctx, r.config.RepackTimeout,
822+
"-d", "--geometric=2", "--write-midx", "--write-bitmap-index"); err != nil {
823+
return err
824+
}
825+
logger.InfoContext(ctx, "Geometric repack completed", "upstream", r.upstreamURL)
826+
return nil
827+
}
828+
829+
// RepackFull runs a full repack that re-selects deltas across all objects with
830+
// a wide search window (-a -d -f --window --depth). Unlike the geometric
831+
// Repack, which reuses existing deltas and only consolidates packs, this
832+
// recovers the cross-pack redundancy that accumulates from incremental fetches
833+
// — materially shrinking the mirror, and therefore the snapshots derived from
834+
// it, at the cost of significant one-time CPU. It is meant to run on a slow
835+
// cadence in between the frequent geometric repacks.
836+
func (r *Repository) RepackFull(ctx context.Context) error {
837+
logger := logging.FromContext(ctx)
838+
839+
timeout := r.config.FullRepackTimeout
840+
if timeout <= 0 {
841+
timeout = r.config.RepackTimeout
842+
}
810843

811-
repackCtx, cancel := context.WithTimeout(ctx, r.config.RepackTimeout)
844+
logger.InfoContext(ctx, "Full repack started", "upstream", r.upstreamURL, "window", fullRepackWindow, "depth", fullRepackDepth)
845+
if err := r.runRepack(ctx, timeout,
846+
"-a", "-d", "-f",
847+
"--window="+strconv.Itoa(fullRepackWindow), "--depth="+strconv.Itoa(fullRepackDepth),
848+
"--write-midx", "--write-bitmap-index"); err != nil {
849+
return err
850+
}
851+
logger.InfoContext(ctx, "Full repack completed", "upstream", r.upstreamURL)
852+
return nil
853+
}
854+
855+
// runRepack executes "git repack <args>" with bounded threads/memory and a
856+
// timeout, cleaning up a stale multi-pack-index.lock on failure.
857+
func (r *Repository) runRepack(ctx context.Context, timeout time.Duration, repackArgs ...string) error {
858+
logger := logging.FromContext(ctx)
859+
860+
repackCtx, cancel := context.WithTimeout(ctx, timeout)
812861
defer cancel()
813862

814863
threads := r.config.RepackThreads
@@ -820,12 +869,15 @@ func (r *Repository) Repack(ctx context.Context) error {
820869
// config uses high values (512m deltaCacheSize, 1g windowMemory) tuned for
821870
// serving performance with many threads. Repack is a background task that
822871
// can afford to be slower in exchange for bounded memory.
823-
// #nosec G204 - r.path is controlled by us
824-
cmd := exec.CommandContext(repackCtx, "git", "-C", r.path,
825-
"-c", "pack.threads="+strconv.Itoa(threads),
872+
args := []string{"-C", r.path,
873+
"-c", "pack.threads=" + strconv.Itoa(threads),
826874
"-c", "pack.windowMemory=256m",
827875
"-c", "pack.deltaCacheSize=128m",
828-
"repack", "-d", "--geometric=2", "--write-midx", "--write-bitmap-index")
876+
"repack"}
877+
args = append(args, repackArgs...)
878+
879+
// #nosec G204 - r.path is controlled by us
880+
cmd := exec.CommandContext(repackCtx, "git", args...)
829881
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
830882
cmd.Cancel = func() error {
831883
return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
@@ -842,8 +894,6 @@ func (r *Repository) Repack(ctx context.Context) error {
842894
}
843895
return errors.Wrapf(err, "git repack: %s", string(output))
844896
}
845-
846-
logger.InfoContext(ctx, "Geometric repack completed", "upstream", r.upstreamURL)
847897
return nil
848898
}
849899

internal/gitclone/manager_test.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,6 +418,35 @@ func TestRepository_Repack(t *testing.T) {
418418
assert.NoError(t, err)
419419
}
420420

421+
func TestRepository_RepackFull(t *testing.T) {
422+
_, ctx := logging.Configure(t.Context(), logging.Config{Level: slog.LevelError})
423+
tmpDir := t.TempDir()
424+
upstreamPath := createBareRepo(t, tmpDir)
425+
426+
clonePath := filepath.Join(tmpDir, "mirror")
427+
cmd := exec.Command("git", "clone", "--mirror", upstreamPath, clonePath)
428+
assert.NoError(t, cmd.Run())
429+
430+
repo := &Repository{
431+
state: StateReady,
432+
config: testRepoConfig(),
433+
path: clonePath,
434+
upstreamURL: upstreamPath,
435+
fetchSem: make(chan struct{}, 1),
436+
}
437+
repo.fetchSem <- struct{}{}
438+
439+
// Unset full-repack-timeout exercises the fallback to repack-timeout.
440+
assert.NoError(t, repo.RepackFull(ctx))
441+
442+
packs, err := filepath.Glob(filepath.Join(clonePath, "objects", "pack", "*.pack"))
443+
assert.NoError(t, err)
444+
assert.True(t, len(packs) > 0, "expected at least one pack file after full repack")
445+
446+
_, err = os.Stat(filepath.Join(clonePath, "objects", "pack", "multi-pack-index"))
447+
assert.NoError(t, err)
448+
}
449+
421450
func TestRepository_Repack_CleansUpStaleLockOnFailure(t *testing.T) {
422451
_, ctx := logging.Configure(t.Context(), logging.Config{Level: slog.LevelError})
423452
tmpDir := t.TempDir()

internal/strategy/git/git.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,8 @@ func Register(r *strategy.Registry, scheduler jobscheduler.Provider, cloneManage
4646
type Config struct {
4747
SnapshotInterval time.Duration `hcl:"snapshot-interval,optional" help:"How often to generate tar.zstd workstation snapshots. 0 disables snapshots." default:"0"`
4848
MirrorSnapshotInterval time.Duration `hcl:"mirror-snapshot-interval,optional" help:"How often to generate mirror snapshots for pod bootstrap. 0 uses snapshot-interval. Defaults to 2h." default:"2h"`
49-
RepackInterval time.Duration `hcl:"repack-interval,optional" help:"How often to run full repack. 0 disables." default:"0"`
49+
RepackInterval time.Duration `hcl:"repack-interval,optional" help:"How often to run the geometric repack (consolidates packs, reuses deltas). 0 disables." default:"0"`
50+
FullRepackInterval time.Duration `hcl:"full-repack-interval,optional" help:"How often to run the full repack (re-selects deltas across all objects, shrinking the mirror). Far more expensive than the geometric repack, so use a slow cadence. 0 disables." default:"0"`
5051
ZstdThreads int `hcl:"zstd-threads,optional" help:"Threads for zstd compression/decompression. 0 = all CPU cores; useful for short-lived CLI invocations but risky on a long-running server where multiple snapshot/restore operations can run concurrently." default:"4"`
5152
BundleCacheTTL time.Duration `hcl:"bundle-cache-ttl,optional" help:"TTL of cached server-side git bundles." default:"2h"`
5253
}
@@ -247,7 +248,7 @@ func (s *Strategy) warmExistingRepos(ctx context.Context) error {
247248
if s.config.SnapshotInterval > 0 {
248249
s.scheduleSnapshotJobs(repo)
249250
}
250-
if s.config.RepackInterval > 0 {
251+
if s.repackEnabled() {
251252
s.scheduleRepackJobs(repo)
252253
}
253254
}
@@ -618,7 +619,7 @@ func (s *Strategy) startClone(ctx context.Context, repo *gitclone.Repository) (r
618619
if s.config.SnapshotInterval > 0 {
619620
s.scheduleSnapshotJobs(repo)
620621
}
621-
if s.config.RepackInterval > 0 {
622+
if s.repackEnabled() {
622623
s.scheduleRepackJobs(repo)
623624
}
624625
return nil
@@ -648,7 +649,7 @@ func (s *Strategy) startClone(ctx context.Context, repo *gitclone.Repository) (r
648649
if s.config.SnapshotInterval > 0 {
649650
s.scheduleSnapshotJobs(repo)
650651
}
651-
if s.config.RepackInterval > 0 {
652+
if s.repackEnabled() {
652653
s.scheduleRepackJobs(repo)
653654
}
654655
return nil

internal/strategy/git/repack.go

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,29 @@ import (
1515
"github.com/block/cachew/internal/gitclone"
1616
)
1717

18+
// repackEnabled reports whether any repack variant is configured to run.
19+
func (s *Strategy) repackEnabled() bool {
20+
return s.config.RepackInterval > 0 || s.config.FullRepackInterval > 0
21+
}
22+
1823
func (s *Strategy) scheduleRepackJobs(repo *gitclone.Repository) {
19-
s.scheduler.SubmitPeriodicJob(repo.UpstreamURL(), "repack-periodic", s.config.RepackInterval, func(ctx context.Context) (returnErr error) {
24+
if s.config.RepackInterval > 0 {
25+
s.schedulePeriodicRepack(repo, "repack-periodic", "repack", s.config.RepackInterval, repo.Repack)
26+
}
27+
if s.config.FullRepackInterval > 0 {
28+
s.schedulePeriodicRepack(repo, "repack-full-periodic", "repack_full", s.config.FullRepackInterval, repo.RepackFull)
29+
}
30+
}
31+
32+
// schedulePeriodicRepack runs repack on the given interval, recording the
33+
// before/after pack count, duration, and outcome. operation distinguishes the
34+
// geometric ("repack") and full ("repack_full") variants in metrics and traces.
35+
func (s *Strategy) schedulePeriodicRepack(repo *gitclone.Repository, jobID, operation string, interval time.Duration, repack func(context.Context) error) {
36+
s.scheduler.SubmitPeriodicJob(repo.UpstreamURL(), jobID, interval, func(ctx context.Context) (returnErr error) {
2037
upstream := repo.UpstreamURL()
21-
ctx, span := tracer.Start(ctx, "git.repack",
38+
ctx, span := tracer.Start(ctx, "git."+operation,
2239
trace.WithAttributes(
23-
attribute.String("cachew.operation", "repack"),
40+
attribute.String("cachew.operation", operation),
2441
attribute.String("cachew.upstream", upstream),
2542
),
2643
)
@@ -32,28 +49,28 @@ func (s *Strategy) scheduleRepackJobs(repo *gitclone.Repository) {
3249
span.End()
3350
}()
3451

35-
// Pack count before and after gives us a direct view of how much
36-
// the geometric repack actually consolidated. A flat before/after
37-
// ratio over time means fragmentation is outpacing the schedule.
52+
// Pack count before and after gives us a direct view of how much the
53+
// repack consolidated. A flat before/after ratio over time means
54+
// fragmentation is outpacing the schedule.
3855
if before, err := countPackFiles(repo.Path()); err == nil {
3956
s.metrics.recordRepackPackCount(ctx, upstream, "before", before)
4057
span.SetAttributes(attribute.Int("cachew.pack_count_before", before))
4158
}
4259

4360
start := time.Now()
44-
err := repo.Repack(ctx)
61+
err := repack(ctx)
4562
status := "success"
4663
if err != nil {
4764
status = "error"
4865
}
49-
s.metrics.recordOperation(ctx, "repack", status, time.Since(start))
66+
s.metrics.recordOperation(ctx, operation, status, time.Since(start))
5067

5168
if after, countErr := countPackFiles(repo.Path()); countErr == nil {
5269
s.metrics.recordRepackPackCount(ctx, upstream, "after", after)
5370
span.SetAttributes(attribute.Int("cachew.pack_count_after", after))
5471
}
5572

56-
return errors.Wrap(err, "repack")
73+
return errors.Wrap(err, operation)
5774
})
5875
}
5976

internal/strategy/git/repack_test.go

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,16 +20,25 @@ func TestRepackInterval(t *testing.T) {
2020
tmpDir := t.TempDir()
2121

2222
tests := []struct {
23-
name string
24-
repackInterval time.Duration
23+
name string
24+
repackInterval time.Duration
25+
fullRepackInterval time.Duration
2526
}{
2627
{
27-
name: "Enabled",
28+
name: "GeometricEnabled",
2829
repackInterval: 24 * time.Hour,
2930
},
3031
{
31-
name: "Disabled",
32-
repackInterval: 0,
32+
name: "FullEnabled",
33+
fullRepackInterval: 7 * 24 * time.Hour,
34+
},
35+
{
36+
name: "BothEnabled",
37+
repackInterval: 24 * time.Hour,
38+
fullRepackInterval: 7 * 24 * time.Hour,
39+
},
40+
{
41+
name: "Disabled",
3342
},
3443
}
3544

@@ -40,7 +49,8 @@ func TestRepackInterval(t *testing.T) {
4049
MirrorRoot: filepath.Join(tmpDir, tt.name),
4150
}, nil)
4251
s, err := git.New(ctx, git.Config{
43-
RepackInterval: tt.repackInterval,
52+
RepackInterval: tt.repackInterval,
53+
FullRepackInterval: tt.fullRepackInterval,
4454
}, newTestScheduler(ctx, t), nil, mux, cm, func() (*githubapp.TokenManager, error) { return nil, nil }) //nolint:nilnil
4555
assert.NoError(t, err)
4656
assert.True(t, s != nil)

internal/strategy/git/snapshot.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -815,7 +815,7 @@ func (s *Strategy) scheduleDeferredMirrorRestore(ctx context.Context, repo *gitc
815815
if s.config.SnapshotInterval > 0 {
816816
s.scheduleSnapshotJobs(repo)
817817
}
818-
if s.config.RepackInterval > 0 {
818+
if s.repackEnabled() {
819819
s.scheduleRepackJobs(repo)
820820
}
821821
return nil

0 commit comments

Comments
 (0)