mirror of
https://github.com/hatchet-dev/hatchet.git
synced 2026-04-22 10:10:07 -05:00
2fdc47a6af
* feat: adds support for multiple slot types, primarily motivated by durable slots --------- Co-authored-by: mrkaye97 <mrkaye97@gmail.com>
290 lines
5.7 KiB
Go
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()
|
|
}
|