161 lines
3.4 KiB
Go
161 lines
3.4 KiB
Go
// Package capacity coordinates engine-local run admission and model-generation
|
|
// concurrency for configured backends.
|
|
package capacity
|
|
|
|
import (
|
|
"container/list"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"sync"
|
|
|
|
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
|
)
|
|
|
|
// ErrCapacityExceeded identifies an admission rejected because a backend's
|
|
// configured run capacity is full.
|
|
var ErrCapacityExceeded = errors.New("backend capacity exceeded")
|
|
|
|
// Manager owns independent backend capacity pools with immutable limits.
|
|
type Manager struct {
|
|
pools map[string]*pool
|
|
}
|
|
|
|
type pool struct {
|
|
mu sync.Mutex
|
|
concurrencyLimit int
|
|
totalCapacity int
|
|
admitted int
|
|
active int
|
|
waiters list.List
|
|
}
|
|
|
|
type waiter struct {
|
|
ready chan struct{}
|
|
element *list.Element
|
|
granted bool
|
|
}
|
|
|
|
// NewManager constructs independent pools from normalized backend policies.
|
|
func NewManager(policies map[string]domain.BackendCapacityPolicy) (*Manager, error) {
|
|
manager := &Manager{
|
|
pools: make(map[string]*pool, len(policies)),
|
|
}
|
|
maxInt := int(^uint(0) >> 1)
|
|
for id, policy := range policies {
|
|
if strings.TrimSpace(id) == "" {
|
|
return nil, errors.New("backend capacity policy ID must not be blank")
|
|
}
|
|
if policy.ConcurrencyLimit <= 0 {
|
|
return nil, fmt.Errorf(
|
|
"backend %q concurrency limit must be positive",
|
|
id,
|
|
)
|
|
}
|
|
if policy.QueueCapacity < 0 {
|
|
return nil, fmt.Errorf(
|
|
"backend %q queue capacity must not be negative",
|
|
id,
|
|
)
|
|
}
|
|
if policy.QueueCapacity > maxInt-policy.ConcurrencyLimit {
|
|
return nil, fmt.Errorf("backend %q total capacity overflows int", id)
|
|
}
|
|
manager.pools[id] = &pool{
|
|
concurrencyLimit: policy.ConcurrencyLimit,
|
|
totalCapacity: policy.ConcurrencyLimit + policy.QueueCapacity,
|
|
}
|
|
}
|
|
return manager, nil
|
|
}
|
|
|
|
// Admit immediately reserves one configured backend run slot. Backends without
|
|
// a configured pool are unlimited.
|
|
func (m *Manager) Admit(ctx context.Context, backendID string) (func(), error) {
|
|
pool := m.getPool(backendID)
|
|
if pool == nil {
|
|
return releaseNothing, nil
|
|
}
|
|
|
|
pool.mu.Lock()
|
|
defer pool.mu.Unlock()
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
if pool.admitted >= pool.totalCapacity {
|
|
return nil, ErrCapacityExceeded
|
|
}
|
|
pool.admitted++
|
|
|
|
var once sync.Once
|
|
return func() {
|
|
once.Do(func() {
|
|
pool.mu.Lock()
|
|
pool.admitted--
|
|
pool.mu.Unlock()
|
|
})
|
|
}, nil
|
|
}
|
|
|
|
func releaseNothing() {}
|
|
|
|
func (m *Manager) getPool(backendID string) *pool {
|
|
if m == nil || backendID == "" {
|
|
return nil
|
|
}
|
|
return m.pools[backendID]
|
|
}
|
|
|
|
func (p *pool) acquire(ctx context.Context) error {
|
|
p.mu.Lock()
|
|
if err := ctx.Err(); err != nil {
|
|
p.mu.Unlock()
|
|
return err
|
|
}
|
|
if p.active < p.concurrencyLimit && p.waiters.Len() == 0 {
|
|
p.active++
|
|
p.mu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
waiter := &waiter{ready: make(chan struct{})}
|
|
waiter.element = p.waiters.PushBack(waiter)
|
|
p.mu.Unlock()
|
|
|
|
select {
|
|
case <-waiter.ready:
|
|
return nil
|
|
case <-ctx.Done():
|
|
p.mu.Lock()
|
|
if !waiter.granted {
|
|
p.waiters.Remove(waiter.element)
|
|
waiter.element = nil
|
|
p.mu.Unlock()
|
|
return ctx.Err()
|
|
}
|
|
p.mu.Unlock()
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func (p *pool) releaseActive() {
|
|
var ready chan struct{}
|
|
|
|
p.mu.Lock()
|
|
if element := p.waiters.Front(); element != nil {
|
|
waiter := element.Value.(*waiter)
|
|
p.waiters.Remove(element)
|
|
waiter.element = nil
|
|
waiter.granted = true
|
|
ready = waiter.ready
|
|
} else {
|
|
p.active--
|
|
}
|
|
p.mu.Unlock()
|
|
|
|
if ready != nil {
|
|
close(ready)
|
|
}
|
|
}
|