Files
hatchet/pkg/scheduling/v1/slot.go
T
Gabe Ruttner 2fdc47a6af feat: multiple slot types (#2927)
* feat: adds support for multiple slot types, primarily motivated by durable slots

---------

Co-authored-by: mrkaye97 <mrkaye97@gmail.com>
2026-02-17 05:43:47 -08:00

290 lines
5.7 KiB
Go

package v1
import (
"fmt"
"slices"
"sync"
"time"
"github.com/google/uuid"
"github.com/hatchet-dev/hatchet/pkg/repository/sqlcv1"
)
// slot expiry is 1 second to account for the 1 second replenish rate, plus 500 ms of buffer
// time for unacked slots to get written back to the database.
const defaultSlotExpiry = 1500 * time.Millisecond
// slotMeta is shared across many slots to avoid duplicating
// metadata that is identical for a worker/type.
type slotMeta struct {
slotType string
actions []string
}
func newSlotMeta(actions []string, slotType string) *slotMeta {
return &slotMeta{
actions: actions,
slotType: slotType,
}
}
type slot struct {
worker *worker
meta *slotMeta
expiresAt *time.Time
additionalAcks []func()
additionalNacks []func()
mu sync.RWMutex
used bool
ackd bool
}
type assignedSlots struct {
slots []*slot
rateLimitAck func()
rateLimitNack func()
}
func (a *assignedSlots) workerId() uuid.UUID {
if len(a.slots) == 0 {
return uuid.Nil
}
return a.slots[0].getWorkerId()
}
func (a *assignedSlots) ack() {
for _, slot := range a.slots {
slot.ack()
}
if a.rateLimitAck != nil {
a.rateLimitAck()
}
}
func (a *assignedSlots) nack() {
for _, slot := range a.slots {
slot.nack()
}
if a.rateLimitNack != nil {
a.rateLimitNack()
}
}
func newSlot(worker *worker, meta *slotMeta) *slot {
expires := time.Now().Add(defaultSlotExpiry)
return &slot{
worker: worker,
meta: meta,
expiresAt: &expires,
}
}
func (s *slot) getWorkerId() uuid.UUID {
return s.worker.ID
}
func (s *slot) getSlotType() (string, error) {
if s.meta == nil {
return "", fmt.Errorf("slot has nil meta")
}
return s.meta.slotType, nil
}
func (s *slot) extendExpiry() {
s.mu.Lock()
defer s.mu.Unlock()
expires := time.Now().Add(defaultSlotExpiry)
s.expiresAt = &expires
}
func (s *slot) active() bool {
s.mu.RLock()
defer s.mu.RUnlock()
return !s.used && s.expiresAt != nil && s.expiresAt.After(time.Now())
}
func (s *slot) isUsed() bool {
s.mu.RLock()
defer s.mu.RUnlock()
return s.used
}
func (s *slot) expired() bool {
s.mu.RLock()
defer s.mu.RUnlock()
return s.expiresAt == nil || s.expiresAt.Before(time.Now())
}
func (s *slot) use(additionalAcks []func(), additionalNacks []func()) bool {
s.mu.Lock()
defer s.mu.Unlock()
if s.used {
return false
}
s.used = true
s.ackd = false
s.additionalAcks = additionalAcks
s.additionalNacks = additionalNacks
return true
}
func (s *slot) ack() {
s.mu.Lock()
defer s.mu.Unlock()
s.ackd = true
for _, ack := range s.additionalAcks {
if ack != nil {
ack()
}
}
s.additionalAcks = nil
s.additionalNacks = nil
}
func (s *slot) nack() {
s.mu.Lock()
defer s.mu.Unlock()
s.used = false
s.ackd = true
for _, nack := range s.additionalNacks {
if nack != nil {
nack()
}
}
s.additionalAcks = nil
s.additionalNacks = nil
}
type rankedValidSlots struct {
ranksToSlots map[int][]*slot
// cachedWorkerRanks is a map of worker id to rank.
cachedWorkerRanks map[uuid.UUID]int
}
func newRankedValidSlots() *rankedValidSlots {
return &rankedValidSlots{
ranksToSlots: make(map[int][]*slot),
cachedWorkerRanks: make(map[uuid.UUID]int),
}
}
func (r *rankedValidSlots) addFromCache(slot *slot) bool {
workerId := slot.getWorkerId()
if rank, ok := r.cachedWorkerRanks[workerId]; ok {
r.addSlot(slot, rank)
return true
}
return false
}
func (r *rankedValidSlots) addSlot(s *slot, rank int) {
if _, ok := r.ranksToSlots[rank]; !ok {
r.ranksToSlots[rank] = make([]*slot, 0)
}
r.ranksToSlots[rank] = append(r.ranksToSlots[rank], s)
}
func (r *rankedValidSlots) order() []*slot {
nonNegativeSlots := make([]*slot, 0)
sortedRanks := make([]int, 0, len(r.ranksToSlots))
for rank := range r.ranksToSlots {
sortedRanks = append(sortedRanks, rank)
}
slices.Sort(sortedRanks)
// iterate through sortedRanks in reverse order
for i := len(sortedRanks) - 1; i >= 0; i-- {
rank := sortedRanks[i]
if rank < 0 {
// skip negative ranks, as they are not valid for scheduling
continue
}
if r.ranksToSlots[rank] != nil {
nonNegativeSlots = append(nonNegativeSlots, r.ranksToSlots[rank]...)
}
}
return nonNegativeSlots
}
// getRankedSlots returns a list of valid slots sorted by preference, discarding any slots that cannot
// match the affinity conditions.
func getRankedSlots(
qi *sqlcv1.V1QueueItem,
labels []*sqlcv1.GetDesiredLabelsRow,
slots []*slot,
) []*slot {
validSlots := newRankedValidSlots()
for _, slot := range slots {
workerId := slot.getWorkerId()
if validSlots.addFromCache(slot) {
continue
}
// if this is a HARD sticky strategy, and there's a desired worker id, it can only be assigned to that
// worker. if there's no desired worker id, we assign to any worker.
if qi.Sticky == sqlcv1.V1StickyStrategyHARD {
if qi.DesiredWorkerID != nil && workerId == *qi.DesiredWorkerID {
validSlots.addSlot(slot, 0)
} else if qi.DesiredWorkerID == nil {
validSlots.addSlot(slot, 0)
}
continue
}
// if this is a SOFT sticky strategy, we should prefer the desired worker, but if it is not
// available, we can assign to any worker.
if qi.Sticky == sqlcv1.V1StickyStrategySOFT {
if qi.DesiredWorkerID != nil && workerId == *qi.DesiredWorkerID {
validSlots.addSlot(slot, 1)
} else {
validSlots.addSlot(slot, 0)
}
continue
}
// if this step has affinity labels, check if the worker has the desired labels, and rank by
// the given affinity
if len(labels) > 0 {
weight := slot.worker.computeWeight(labels)
validSlots.addSlot(slot, weight)
continue
}
// if this step has no sticky strategy or affinity labels, add the slot with rank 0
validSlots.addSlot(slot, 0)
}
return validSlots.order()
}