Skip to content

Commit 0c3b5af

Browse files
committed
Stop periodic jobs for idle repos
Repos that haven't had a client request in 24h (configurable via idle-timeout) no longer re-arm their snapshot/repack periodic jobs. When a request arrives for an idle repo, the jobs are re-scheduled.
1 parent 572fc02 commit 0c3b5af

10 files changed

Lines changed: 199 additions & 27 deletions

File tree

cmd/cachewd/main.go

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -327,15 +327,25 @@ func fatalIfError(ctx context.Context, logger *slog.Logger, err error, msg strin
327327
os.Exit(1)
328328
}
329329

330-
// extractPathPrefix extracts the strategy name, path prefix from a request path.
331-
// Examples: /git/... -> "git", /gomod/... -> "gomod", /api/v1/... -> "api".
330+
// extractPathPrefix returns the first path segment, or the first two
331+
// non-version segments for /api/ so that object/stats/namespaces aren't
332+
// lumped together (e.g. /api/v1/object/... -> "api/object").
332333
func extractPathPrefix(path string) string {
333-
if path == "" || path == "/" {
334+
parts := strings.SplitN(strings.Trim(path, "/"), "/", 4)
335+
if len(parts) == 0 || parts[0] == "" {
334336
return ""
335337
}
336-
trimmed := strings.TrimPrefix(path, "/")
337-
prefix, _, _ := strings.Cut(trimmed, "/")
338-
return prefix
338+
if parts[0] != "api" || len(parts) < 2 {
339+
return parts[0]
340+
}
341+
i := 1
342+
if i < len(parts) && len(parts[i]) > 0 && parts[i][0] == 'v' {
343+
i++
344+
}
345+
if i < len(parts) {
346+
return "api/" + parts[i]
347+
}
348+
return "api"
339349
}
340350

341351
func newServer(

cmd/cachewd/main_test.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package main
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
)
8+
9+
func TestExtractPathPrefix(t *testing.T) {
10+
tests := []struct {
11+
path string
12+
want string
13+
}{
14+
{"", ""},
15+
{"/", ""},
16+
{"/git/github.com/org/repo/info/refs", "git"},
17+
{"/gomod/proxy.golang.org/x/mod/@latest", "gomod"},
18+
{"/hermit/packages/go-1.22.0.tar.gz", "hermit"},
19+
{"/api/v1/object/brew/some-key", "api/object"},
20+
{"/api/v1/stats", "api/stats"},
21+
{"/api/v1/namespaces", "api/namespaces"},
22+
{"/api/object/ns/key", "api/object"},
23+
{"/api", "api"},
24+
{"/api/", "api"},
25+
}
26+
for _, tt := range tests {
27+
t.Run(tt.path, func(t *testing.T) {
28+
assert.Equal(t, tt.want, extractPathPrefix(tt.path))
29+
})
30+
}
31+
}

go.mod

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ require (
1212
github.com/minio/minio-go/v7 v7.0.100
1313
github.com/open-policy-agent/opa v1.15.2
1414
github.com/prometheus/client_golang v1.23.2
15+
github.com/stretchr/testify v1.11.1
1516
go.etcd.io/bbolt v1.4.3
1617
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0
1718
go.opentelemetry.io/contrib/instrumentation/runtime v0.68.0
@@ -33,6 +34,7 @@ require (
3334
github.com/beorn7/perks v1.0.1 // indirect
3435
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
3536
github.com/cespare/xxhash/v2 v2.3.0 // indirect
37+
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
3638
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
3739
github.com/dlclark/regexp2 v1.11.5 // indirect
3840
github.com/dustin/go-humanize v1.0.1 // indirect
@@ -59,6 +61,7 @@ require (
5961
github.com/minio/md5-simd v1.1.2 // indirect
6062
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
6163
github.com/philhofer/fwd v1.2.0 // indirect
64+
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
6265
github.com/prometheus/client_model v0.6.2 // indirect
6366
github.com/prometheus/common v0.67.5 // indirect
6467
github.com/prometheus/otlptranslator v1.0.0 // indirect
@@ -87,6 +90,7 @@ require (
8790
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
8891
google.golang.org/grpc v1.80.0 // indirect
8992
google.golang.org/protobuf v1.36.11 // indirect
93+
gopkg.in/yaml.v3 v3.0.1 // indirect
9094
sigs.k8s.io/yaml v1.6.0 // indirect
9195
)
9296

internal/gitclone/manager.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"strconv"
1212
"strings"
1313
"sync"
14+
"sync/atomic"
1415
"syscall"
1516
"time"
1617

@@ -85,6 +86,19 @@ type Repository struct {
8586
refCheckValid bool
8687
fetchSem chan struct{}
8788
credentialProvider CredentialProvider
89+
lastAccessed atomic.Int64
90+
}
91+
92+
func (r *Repository) TouchAccessed() {
93+
r.lastAccessed.Store(time.Now().UnixNano())
94+
}
95+
96+
func (r *Repository) LastAccessed() time.Time {
97+
ns := r.lastAccessed.Load()
98+
if ns == 0 {
99+
return time.Time{}
100+
}
101+
return time.Unix(0, ns)
88102
}
89103

90104
type Manager struct {
@@ -199,6 +213,7 @@ func (m *Manager) GetOrCreate(_ context.Context, upstreamURL string) (*Repositor
199213
fetchSem: make(chan struct{}, 1),
200214
credentialProvider: m.credentialProvider,
201215
}
216+
repo.lastAccessed.Store(time.Now().UnixNano())
202217

203218
headFile := filepath.Join(clonePath, "HEAD")
204219
if _, err := os.Stat(headFile); err == nil {

internal/jobscheduler/jobs.go

Lines changed: 77 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"runtime"
88
"strings"
99
"sync"
10+
"sync/atomic"
1011
"time"
1112

1213
"github.com/alecthomas/errors"
@@ -22,6 +23,16 @@ type Config struct {
2223
SchedulerDB string `hcl:"scheduler-db" help:"Path to the scheduler state database." default:"${CACHEW_STATE}/scheduler.db"`
2324
}
2425

26+
// idledPeriodicJob stores enough information to re-arm a periodic job that was
27+
// stopped due to queue inactivity.
28+
type idledPeriodicJob struct {
29+
queue string
30+
id string
31+
interval time.Duration
32+
run func(ctx context.Context) error
33+
idleTimeout time.Duration
34+
}
35+
2536
type queueJob struct {
2637
id string
2738
queue string
@@ -49,9 +60,14 @@ type Scheduler interface {
4960
// Jobs run concurrently across queues, but never within a queue.
5061
Submit(queue, id string, run func(ctx context.Context) error)
5162
// SubmitPeriodicJob submits a job to the queue that runs immediately, and then periodically after the interval.
63+
// If idleTimeout is provided, the job stops re-arming when the queue has not been touched (via Touch) for
64+
// longer than the timeout. Calling Touch on an idle queue re-arms all its stopped periodic jobs.
5265
//
5366
// Jobs run concurrently across queues, but never within a queue.
54-
SubmitPeriodicJob(queue, id string, interval time.Duration, run func(ctx context.Context) error)
67+
SubmitPeriodicJob(queue, id string, interval time.Duration, run func(ctx context.Context) error, idleTimeout ...time.Duration)
68+
// Touch records activity for a queue, resetting its idle timer. If the queue had periodic jobs that were
69+
// stopped due to inactivity, they are re-scheduled.
70+
Touch(queue string)
5571
}
5672

5773
type prefixedScheduler struct {
@@ -63,8 +79,12 @@ func (p *prefixedScheduler) Submit(queue, id string, run func(ctx context.Contex
6379
p.scheduler.Submit(queue, p.prefix+id, run)
6480
}
6581

66-
func (p *prefixedScheduler) SubmitPeriodicJob(queue, id string, interval time.Duration, run func(ctx context.Context) error) {
67-
p.scheduler.SubmitPeriodicJob(queue, p.prefix+id, interval, run)
82+
func (p *prefixedScheduler) SubmitPeriodicJob(queue, id string, interval time.Duration, run func(ctx context.Context) error, idleTimeout ...time.Duration) {
83+
p.scheduler.SubmitPeriodicJob(queue, p.prefix+id, interval, run, idleTimeout...)
84+
}
85+
86+
func (p *prefixedScheduler) Touch(queue string) {
87+
p.scheduler.Touch(queue)
6888
}
6989

7090
func (p *prefixedScheduler) WithQueuePrefix(prefix string) Scheduler {
@@ -85,11 +105,13 @@ type RootScheduler struct {
85105
// ctx is cancelled when the scheduler is shutting down. Periodic re-arm
86106
// goroutines select on it so they exit cleanly instead of submitting to a
87107
// dead scheduler.
88-
ctx context.Context //nolint:containedctx
89-
cancel context.CancelFunc
90-
wg sync.WaitGroup
91-
store ScheduleStore
92-
metrics *schedulerMetrics
108+
ctx context.Context //nolint:containedctx
109+
cancel context.CancelFunc
110+
wg sync.WaitGroup
111+
store ScheduleStore
112+
metrics *schedulerMetrics
113+
lastTouched sync.Map // queue -> *atomic.Int64 (unix nanos)
114+
idledJobs sync.Map // jobKey -> *idledPeriodicJob
93115
}
94116

95117
var _ Scheduler = &RootScheduler{}
@@ -173,7 +195,18 @@ func (q *RootScheduler) Submit(queue, id string, run func(ctx context.Context) e
173195
q.cond.Signal()
174196
}
175197

176-
func (q *RootScheduler) SubmitPeriodicJob(queue, id string, interval time.Duration, run func(ctx context.Context) error) {
198+
func (q *RootScheduler) SubmitPeriodicJob(queue, id string, interval time.Duration, run func(ctx context.Context) error, idleTimeout ...time.Duration) {
199+
var timeout time.Duration
200+
if len(idleTimeout) > 0 {
201+
timeout = idleTimeout[0]
202+
}
203+
if timeout > 0 {
204+
q.touchQueue(queue)
205+
}
206+
q.submitPeriodicJob(queue, id, interval, run, timeout)
207+
}
208+
209+
func (q *RootScheduler) submitPeriodicJob(queue, id string, interval time.Duration, run func(ctx context.Context) error, idleTimeout time.Duration) {
177210
if q.ctx.Err() != nil {
178211
return
179212
}
@@ -192,7 +225,14 @@ func (q *RootScheduler) SubmitPeriodicJob(queue, id string, interval time.Durati
192225
// to wake and submit to a dead scheduler. The new pod's
193226
// warmExistingRepos re-registers periodic jobs on startup.
194227
go q.sleepThenSubmit(interval, func() {
195-
q.SubmitPeriodicJob(queue, id, interval, run)
228+
if idleTimeout > 0 && q.isQueueIdle(queue, idleTimeout) {
229+
logging.FromContext(ctx).InfoContext(ctx, "Periodic job idled out", "queue", queue, "job", id)
230+
q.idledJobs.Store(key, &idledPeriodicJob{
231+
queue: queue, id: id, interval: interval, run: run, idleTimeout: idleTimeout,
232+
})
233+
return
234+
}
235+
q.submitPeriodicJob(queue, id, interval, run, idleTimeout)
196236
})
197237
return errors.WithStack(err)
198238
})
@@ -204,6 +244,33 @@ func (q *RootScheduler) SubmitPeriodicJob(queue, id string, interval time.Durati
204244
go q.sleepThenSubmit(delay, submit)
205245
}
206246

247+
// Touch records activity for a queue, resetting its idle timer. If the queue
248+
// had periodic jobs that were stopped due to inactivity, they are re-scheduled.
249+
func (q *RootScheduler) Touch(queue string) {
250+
q.touchQueue(queue)
251+
q.idledJobs.Range(func(key, value any) bool {
252+
job := value.(*idledPeriodicJob)
253+
if job.queue == queue {
254+
q.idledJobs.Delete(key)
255+
q.submitPeriodicJob(job.queue, job.id, job.interval, job.run, job.idleTimeout)
256+
}
257+
return true
258+
})
259+
}
260+
261+
func (q *RootScheduler) touchQueue(queue string) {
262+
val, _ := q.lastTouched.LoadOrStore(queue, &atomic.Int64{})
263+
val.(*atomic.Int64).Store(time.Now().UnixNano())
264+
}
265+
266+
func (q *RootScheduler) isQueueIdle(queue string, timeout time.Duration) bool {
267+
val, ok := q.lastTouched.Load(queue)
268+
if !ok {
269+
return true
270+
}
271+
return time.Since(time.Unix(0, val.(*atomic.Int64).Load())) > timeout
272+
}
273+
207274
// sleepThenSubmit waits for d, then runs fn — unless the scheduler is
208275
// shutting down, in which case it returns immediately.
209276
func (q *RootScheduler) sleepThenSubmit(d time.Duration, fn func()) {

internal/jobscheduler/jobs_test.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,38 @@ func TestJobSchedulerPeriodicJob(t *testing.T) {
263263
}, "periodic job should execute multiple times")
264264
}
265265

266+
func TestJobSchedulerPeriodicJobStopsOnIdle(t *testing.T) {
267+
_, ctx := logging.Configure(context.Background(), logging.Config{Level: slog.LevelError})
268+
ctx, cancel := context.WithCancel(ctx)
269+
defer cancel()
270+
271+
scheduler, err := jobscheduler.New(ctx, jobscheduler.Config{Concurrency: 1})
272+
assert.NoError(t, err)
273+
t.Cleanup(func() { scheduler.Close() })
274+
275+
var executions atomic.Int32
276+
done := make(chan struct{})
277+
278+
// Submit with a nanosecond idle timeout. The initial SubmitPeriodicJob
279+
// touches the queue so the first execution runs. By the time the re-arm
280+
// check fires, the nanosecond timeout has elapsed and the job stops.
281+
scheduler.SubmitPeriodicJob("queue1", "idle-job", time.Nanosecond, func(_ context.Context) error {
282+
executions.Add(1)
283+
close(done)
284+
return nil
285+
}, time.Nanosecond)
286+
287+
<-done
288+
289+
// Cancel and drain the scheduler. If the job was re-armed, the worker
290+
// would execute it before Wait returns because concurrency is 1.
291+
cancel()
292+
scheduler.Wait()
293+
294+
assert.Equal(t, int32(1), executions.Load(),
295+
"periodic job should not re-arm after idle timeout")
296+
}
297+
266298
func TestJobSchedulerPeriodicJobWithError(t *testing.T) {
267299
_, ctx := logging.Configure(context.Background(), logging.Config{Level: slog.LevelError})
268300
ctx, cancel := context.WithCancel(ctx)

internal/strategy/git/git.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ type Config struct {
4949
RepackInterval time.Duration `hcl:"repack-interval,optional" help:"How often to run full repack. 0 disables." default:"0"`
5050
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"`
5151
BundleCacheTTL time.Duration `hcl:"bundle-cache-ttl,optional" help:"TTL of cached server-side git bundles." default:"2h"`
52+
IdleTimeout time.Duration `hcl:"idle-timeout,optional" help:"Stop periodic jobs for repos with no client requests for this duration. 0 disables." default:"72h"`
5253
}
5354

5455
type Strategy struct {
@@ -197,6 +198,11 @@ func (s *Strategy) Ready() bool {
197198
return s.ready.Load()
198199
}
199200

201+
func (s *Strategy) touchRepo(repo *gitclone.Repository) {
202+
repo.TouchAccessed()
203+
s.scheduler.Touch(repo.UpstreamURL())
204+
}
205+
200206
// SetMetadataStore enables the per-repo clone histogram and schedules its
201207
// daily reaper. Called by config.Load after the metadata backend is built.
202208
func (s *Strategy) SetMetadataStore(store *metadatadb.Store) {
@@ -332,6 +338,7 @@ func (s *Strategy) handleGitRequest(w http.ResponseWriter, r *http.Request, host
332338
http.Error(w, "Internal server error", http.StatusInternalServerError)
333339
return
334340
}
341+
s.touchRepo(repo)
335342

336343
// Increment after GetOrCreate so unvalidated URLs can't bloat the keyspace.
337344
if isClone, cerr := RequestIsClone(pathValue, r); cerr != nil {

internal/strategy/git/refs.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ func (s *Strategy) handleEnsureRefs(w http.ResponseWriter, r *http.Request, host
7272
http.Error(w, "internal server error", http.StatusInternalServerError)
7373
return
7474
}
75+
s.touchRepo(repo)
7576

7677
if repo.State() != gitclone.StateReady {
7778
if err := s.ensureCloneReady(ctx, repo); err != nil {

internal/strategy/git/repack.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,5 +19,5 @@ func (s *Strategy) scheduleRepackJobs(repo *gitclone.Repository) {
1919
}
2020
s.metrics.recordOperation(ctx, "repack", status, time.Since(start))
2121
return errors.Wrap(err, "repack")
22-
})
22+
}, s.config.IdleTimeout)
2323
}

0 commit comments

Comments
 (0)