Commit 973797757f

973797757fb5cee4c9aeb1ad6d4cf64061f840b0

parent: c0f2228f98

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-29 16:29 UTC

config: push_concurrency, push_per_principal, push_queue, push_queue_wait

Ref #308

Layout: unified · split

internal/config/config.go +44 −9
@@ -35,6 +35,16 @@ const (
3535 DefaultPackQueueWait = time.Minute
3636)
3737
38// Push defaults: receive-pack indexes what it is sent, a core each for a
39// large push, and has its own budget so clones and pushes cannot starve
40// each other.
41const (
42 DefaultPushConcurrency = 2
43 DefaultPushPerPrincipal = 1
44 DefaultPushQueue = 16
45 DefaultPushQueueWait = time.Minute
46)
47
3848type Config struct {
3949 Server Server `toml:"server"`
4050 SSH SSH `toml:"ssh"`
@@ -235,12 +245,32 @@ type Limits struct {
235245 PackPerPrincipal int `toml:"pack_per_principal"`
236246 PackQueue int `toml:"pack_queue"`
237247 PackQueueWait string `toml:"pack_queue_wait"`
248 // PushConcurrency caps receive-pack running at once over SSH, with
249 // its hooks; PushPerPrincipal caps it per account or deploy key.
250 // PushQueue and PushQueueWait bound the wait for a slot. The counts
251 // read like the pack_* ones: 0 takes the default, negative is off.
252 PushConcurrency int `toml:"push_concurrency"`
253 PushPerPrincipal int `toml:"push_per_principal"`
254 PushQueue int `toml:"push_queue"`
255 PushQueueWait string `toml:"push_queue_wait"`
238256}
239257
240258// PackLimits resolves the pack_* settings for packlimit.New. A zero
241259// max or per is no bound; an unbounded queue is math.MaxInt, since
242260// packlimit reads a zero queue as no queue at all.
243261func (l Limits) PackLimits() (max, per, queue int, wait time.Duration) {
262 return resolveLimits(l.PackConcurrency, l.PackPerPrincipal, l.PackQueue, l.PackQueueWait,
263 DefaultPackConcurrency, DefaultPackPerPrincipal, DefaultPackQueue, DefaultPackQueueWait)
264}
265
266// PushLimits resolves the push_* settings the way PackLimits does the
267// pack_* ones.
268func (l Limits) PushLimits() (max, per, queue int, wait time.Duration) {
269 return resolveLimits(l.PushConcurrency, l.PushPerPrincipal, l.PushQueue, l.PushQueueWait,
270 DefaultPushConcurrency, DefaultPushPerPrincipal, DefaultPushQueue, DefaultPushQueueWait)
271}
272
273func resolveLimits(maxV, perV, queueV int, waitV string, maxDef, perDef, queueDef int, waitDef time.Duration) (max, per, queue int, wait time.Duration) {
244274 pick := func(v, def int) int {
245275 switch {
246276 case v == 0:
@@ -250,16 +280,15 @@ func (l Limits) PackLimits() (max, per, queue int, wait time.Duration) {
250280 }
251281 return v
252282 }
253 queue = pick(l.PackQueue, DefaultPackQueue)
254 if l.PackQueue < 0 {
283 queue = pick(queueV, queueDef)
284 if queueV < 0 {
255285 queue = math.MaxInt
256286 }
257 wait = DefaultPackQueueWait
258 if d, err := time.ParseDuration(l.PackQueueWait); err == nil && d > 0 {
287 wait = waitDef
288 if d, err := time.ParseDuration(waitV); err == nil && d > 0 {
259289 wait = d
260290 }
261 return pick(l.PackConcurrency, DefaultPackConcurrency),
262 pick(l.PackPerPrincipal, DefaultPackPerPrincipal), queue, wait
291 return pick(maxV, maxDef), pick(perV, perDef), queue, wait
263292}
264293
265294type Mail struct {
@@ -619,9 +648,15 @@ func (c Config) Validate() error {
619648 if c.Limits.MaxReposPerUser < 0 || c.Limits.MaxBytesPerUser < 0 || c.Limits.MaxSnippetsPerUser < 0 {
620649 errs = append(errs, errors.New("limits.max_repos_per_user, max_bytes_per_user and max_snippets_per_user must not be negative"))
621650 }
622 if w := c.Limits.PackQueueWait; w != "" {
623 if d, err := time.ParseDuration(w); err != nil || d <= 0 {
624 errs = append(errs, fmt.Errorf("limits.pack_queue_wait %q must be a positive duration such as 60s", w))
651 for _, w := range []struct{ name, val string }{
652 {"pack_queue_wait", c.Limits.PackQueueWait},
653 {"push_queue_wait", c.Limits.PushQueueWait},
654 } {
655 if w.val == "" {
656 continue
657 }
658 if d, err := time.ParseDuration(w.val); err != nil || d <= 0 {
659 errs = append(errs, fmt.Errorf("limits.%s %q must be a positive duration such as 60s", w.name, w.val))
625660 }
626661 }
627662 if c.Push.Enabled {
internal/config/config_test.go +32
@@ -63,6 +63,33 @@ func TestPackLimits(t *testing.T) {
6363 }
6464}
6565
66// push_* resolve like pack_*, from their own defaults.
67func TestPushLimits(t *testing.T) {
68 max, per, queue, wait := Limits{}.PushLimits()
69 if max != DefaultPushConcurrency || per != DefaultPushPerPrincipal || queue != DefaultPushQueue || wait != DefaultPushQueueWait {
70 t.Fatalf("defaults: %d %d %d %s", max, per, queue, wait)
71 }
72 if max != 2 || per != 1 || queue != 16 || wait != time.Minute {
73 t.Fatalf("defaults moved: %d %d %d %s", max, per, queue, wait)
74 }
75 max, per, queue, wait = Limits{PushConcurrency: -1, PushPerPrincipal: -1, PushQueue: -1, PushQueueWait: "5s"}.PushLimits()
76 if max != 0 || per != 0 || queue != math.MaxInt || wait != 5*time.Second {
77 t.Fatalf("off: %d %d %d %s", max, per, queue, wait)
78 }
79 cfg, err := Load(writeConfig(t, minimal+"\n[limits]\npush_concurrency = 4\npush_per_principal = 2\npush_queue = 8\npush_queue_wait = \"30s\"\n"))
80 if err != nil {
81 t.Fatal(err)
82 }
83 max, per, queue, wait = cfg.Limits.PushLimits()
84 if max != 4 || per != 2 || queue != 8 || wait != 30*time.Second {
85 t.Fatalf("loaded: %d %d %d %s", max, per, queue, wait)
86 }
87 // The pack budget is not read from the push settings.
88 if pm, _, _, _ := cfg.Limits.PackLimits(); pm != DefaultPackConcurrency {
89 t.Fatalf("pack_concurrency %d, want the default", pm)
90 }
91}
92
6693func TestContradictions(t *testing.T) {
6794 cases := []struct {
6895 name string
@@ -74,6 +101,11 @@ func TestContradictions(t *testing.T) {
74101 minimal + "\n[limits]\npack_queue_wait = \"soon\"\n",
75102 "limits.pack_queue_wait",
76103 },
104 {
105 "bad push_queue_wait",
106 minimal + "\n[limits]\npush_queue_wait = \"-5s\"\n",
107 "limits.push_queue_wait",
108 },
77109 {
78110 "registration open without smtp",
79111 minimal + "\n[registration]\nmode = \"open\"\n",
internal/packlimit/packlimit.go +18 −7
@@ -1,8 +1,9 @@
1// Package packlimit bounds concurrent git pack generation. upload-pack
2// and upload-archive over SSH, smart HTTP and git:// draw on one
3// budget: a global cap, a cap per principal (an account, or a client
4// address on the anonymous transports), and a bounded queue whose
5// waiters give up after a fixed wait or when the client goes away.
1// Package packlimit bounds concurrent git processes. upload-pack and
2// upload-archive over SSH, smart HTTP and git:// draw on one budget,
3// receive-pack on a second: each a global cap, a cap per principal (an
4// account, a deploy key, or a client address on the anonymous
5// transports), and a bounded queue whose waiters give up after a fixed
6// wait or when the client goes away.
67// Waiters are not served in order; a new arrival can take a freed slot
78// ahead of them, and the wait bounds how long any one of them waits.
89package packlimit
@@ -24,6 +25,7 @@ var (
2425type Limiter struct {
2526 max, per, queue int
2627 wait time.Duration
28 name string // what is limited, for the refusal log
2729
2830 // Principals starting with class may hold at most classCap slots
2931 // between them; classCap 0 is no class cap.
@@ -45,7 +47,7 @@ func New(max, per, queue int, wait time.Duration) *Limiter {
4547 if max <= 0 {
4648 return nil
4749 }
48 return &Limiter{max: max, per: per, queue: queue, wait: wait,
50 return &Limiter{max: max, per: per, queue: queue, wait: wait, name: "pack",
4951 held: map[string]int{}, waiting: map[string]int{}, changed: make(chan struct{}),
5052 warned: map[string]time.Time{}}
5153}
@@ -71,10 +73,19 @@ func (l *Limiter) Refused(transport, principal string, err error) {
7173 if errors.Is(err, ErrGone) {
7274 reason = "gone"
7375 }
74 slog.Warn("pack limit: request turned away (logged at most once a minute per transport)",
76 slog.Warn(l.name+" limit: request turned away (logged at most once a minute per transport)",
7577 "transport", transport, "class", class, "reason", reason)
7678}
7779
80// Name sets what the refusal log calls this limit ("pack" unless set).
81// Call it before the limiter is in use.
82func (l *Limiter) Name(name string) {
83 if l == nil {
84 return
85 }
86 l.name = name
87}
88
7889// CapClass caps the slots that principals starting with prefix may hold
7990// between them. Call it before the limiter is in use.
8091func (l *Limiter) CapClass(prefix string, n int) {
internal/packlimit/packlimit_test.go +24
@@ -313,3 +313,27 @@ func TestRefusedLogsOncePerTransport(t *testing.T) {
313313 var none *Limiter
314314 none.Refused("git", "ip:x", ErrBusy)
315315}
316
317// Two limiters log apart: a named one says what it limits, and its
318// once-a-minute window does not silence the other's.
319func TestRefusedNamesTheLimit(t *testing.T) {
320 var buf bytes.Buffer
321 old := slog.Default()
322 slog.SetDefault(slog.New(slog.NewTextHandler(&buf, nil)))
323 t.Cleanup(func() { slog.SetDefault(old) })
324
325 packs := New(1, 0, 0, time.Second)
326 pushes := New(1, 0, 0, time.Second)
327 pushes.Name("push")
328 packs.Refused("ssh", "user:4", ErrBusy)
329 pushes.Refused("ssh", "key:9", ErrBusy)
330 out := buf.String()
331 if !strings.Contains(out, `"pack limit: request turned away`) || !strings.Contains(out, `"push limit: request turned away`) {
332 t.Fatalf("want one line per limit:\n%s", out)
333 }
334 if !strings.Contains(out, "class=key") || strings.Contains(out, "key:9") {
335 t.Fatalf("deploy key principal logged or class missing:\n%s", out)
336 }
337 var none *Limiter
338 none.Name("push")
339}