mirror of
https://github.com/k3s-io/kubernetes.git
synced 2026-07-23 19:16:25 +00:00
324 lines
12 KiB
Go
324 lines
12 KiB
Go
/*
|
|
Copyright 2025 The Kubernetes Authors.
|
|
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
you may not use this file except in compliance with the License.
|
|
You may obtain a copy of the License at
|
|
|
|
http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
Unless required by applicable law or agreed to in writing, software
|
|
distributed under the License is distributed on an "AS IS" BASIS,
|
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
See the License for the specific language governing permissions and
|
|
limitations under the License.
|
|
*/
|
|
|
|
package apidispatcher
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"sync"
|
|
|
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
"k8s.io/apimachinery/pkg/types"
|
|
"k8s.io/apimachinery/pkg/util/sets"
|
|
fwk "k8s.io/kube-scheduler/framework"
|
|
"k8s.io/kubernetes/pkg/scheduler/metrics"
|
|
"k8s.io/utils/buffer"
|
|
)
|
|
|
|
// queuedAPICall contains an API call and other fields needed for its processing by the callQueue.
|
|
type queuedAPICall struct {
|
|
fwk.APICall
|
|
// onFinish is a channel to which the result of the API call's execution is sent in a non-blocking way.
|
|
// sendOnFinish helper should always be used instead of a direct channel.
|
|
onFinish chan<- error
|
|
// callID provides a unique identity for this enqueued call.
|
|
// It is used during finalization to differentiate this instance from a newer one that may have replaced it
|
|
// while this call was in-flight.
|
|
callID int
|
|
}
|
|
|
|
// sendOnFinish performs a non-blocking send of an error to the onFinish channel.
|
|
// This method should be used instead of direct operation on the channel.
|
|
func (qc *queuedAPICall) sendOnFinish(err error) {
|
|
if qc.onFinish != nil {
|
|
select {
|
|
case qc.onFinish <- err:
|
|
default:
|
|
}
|
|
}
|
|
}
|
|
|
|
// callQueue manages the state and lifecycle of API calls.
|
|
// It handles all logic related to merging, queuing, and finalization of calls.
|
|
//
|
|
// For any given object (identified by its UID), at most one API
|
|
// call is present in the callsQueue and apiCalls at any time. This prevents race conditions
|
|
// and ensures sequential execution for operations on the same object.
|
|
//
|
|
// When a new call is added for an object that is already being tracked,
|
|
// the controller reconciles them by merging the calls, overwriting the older one,
|
|
// or skipping the new one entirely based on relevance.
|
|
//
|
|
// When an existing call is in-progress and a new one for the same object is added,
|
|
// it will be inserted to the callsQueue when the existing one is finished (finalized).
|
|
//
|
|
// The controller tracks the state of all calls (pending, in-flight)
|
|
// to coordinate the asynchronous execution flow managed by the APIDispatcher.
|
|
type callQueue struct {
|
|
lock sync.RWMutex
|
|
cond *sync.Cond
|
|
closed bool
|
|
|
|
// apiCallRelevances maps all possible APICallTypes to a relevance value.
|
|
// A more relevant API call should overwrite a less relevant one for the same object.
|
|
// Types of the same relevance should only be defined for different object types.
|
|
apiCallRelevances fwk.APICallRelevances
|
|
// callIDCounter is a monotonically increasing counter used to assign a unique callID
|
|
// to each queuedAPICall added to the callQueue.
|
|
callIDCounter int
|
|
|
|
// apiCalls stores details of API calls for objects that are currently enqueued or in-flight.
|
|
// Must be used under lock.
|
|
apiCalls map[types.UID]*queuedAPICall
|
|
// callsQueue is a FIFO queue that stores the object UIDs of (pending) API calls waiting to be executed.
|
|
// Must be used under lock.
|
|
callsQueue buffer.Ring[types.UID]
|
|
// inFlightEntities stores object UIDs of API calls that are currently in-flight
|
|
// (i.e., their execution goroutine has been dispatched).
|
|
// Must be used under lock.
|
|
inFlightEntities sets.Set[types.UID]
|
|
}
|
|
|
|
// newCallQueue returns a new callQueue object.
|
|
func newCallQueue(apiCallRelevances fwk.APICallRelevances) *callQueue {
|
|
q := callQueue{
|
|
apiCallRelevances: apiCallRelevances,
|
|
apiCalls: make(map[types.UID]*queuedAPICall),
|
|
inFlightEntities: sets.New[types.UID](),
|
|
}
|
|
q.cond = sync.NewCond(&q.lock)
|
|
|
|
return &q
|
|
}
|
|
|
|
// isLessRelevant returns true if newCall is less relevant than oldCall.
|
|
func (cq *callQueue) isLessRelevant(oldCall, newCall fwk.APICallType) bool {
|
|
return cq.apiCallRelevances[newCall] < cq.apiCallRelevances[oldCall]
|
|
}
|
|
|
|
// merge compares a new API call with an existing one for the same object.
|
|
// Based on their relevance and type, it determines whether to skip the new call,
|
|
// overwrite the old one, or merge them.
|
|
// Must be called under cq.lock.
|
|
func (cq *callQueue) merge(oldAPICall, apiCall *queuedAPICall) error {
|
|
if cq.isLessRelevant(oldAPICall.CallType(), apiCall.CallType()) {
|
|
// The new API call is less relevant than the existing one, so skip it.
|
|
err := fmt.Errorf("a more relevant call is already enqueued for this object: %w", fwk.ErrCallSkipped)
|
|
apiCall.sendOnFinish(err)
|
|
return err
|
|
}
|
|
if oldAPICall.CallType() != apiCall.CallType() {
|
|
if cq.inFlightEntities.Has(oldAPICall.UID()) {
|
|
// Old API call is in-flight. The new call needs to wait for the old one to finish.
|
|
return nil
|
|
}
|
|
// API call types don't match, so we overwrite the old one.
|
|
// Update the pending calls metric if the old call is not in-flight.
|
|
metrics.AsyncAPIPendingCalls.WithLabelValues(string(oldAPICall.CallType())).Dec()
|
|
metrics.AsyncAPIPendingCalls.WithLabelValues(string(apiCall.CallType())).Inc()
|
|
oldAPICall.sendOnFinish(fmt.Errorf("a more relevant call was enqueued for this object: %w", fwk.ErrCallOverwritten))
|
|
return nil
|
|
}
|
|
|
|
// Send a concrete APICall object to allow for a type assertion.
|
|
err := apiCall.Merge(oldAPICall.APICall)
|
|
if err != nil {
|
|
err := fmt.Errorf("failed to merge API calls: %w", err)
|
|
apiCall.sendOnFinish(err)
|
|
return err
|
|
}
|
|
oldAPICall.sendOnFinish(fmt.Errorf("a call of the same type was enqueued for this object: %w", fwk.ErrCallOverwritten))
|
|
|
|
// If we still need the API call or the previous one is in-flight, we proceed.
|
|
return nil
|
|
}
|
|
|
|
// enqueue handles the logic of adding a new or updated API call to the controller's state.
|
|
//
|
|
// First, it checks if the apiCall is a no-op. If it is, and no other call for the same object
|
|
// is currently in-flight, the call is skipped and an ErrCallSkipped is returned.
|
|
//
|
|
// If the call is not skipped, it is stored internally. If no previous call for the object
|
|
// was being tracked (oldCallPresent is false), the object's UID is added to the processing
|
|
// queue to be picked up by a worker.
|
|
//
|
|
// This logic ensures only one call per object is active in the queue at a time.
|
|
// Must be called under cq.lock.
|
|
func (cq *callQueue) enqueue(apiCall *queuedAPICall, oldCallPresent bool) error {
|
|
noOp := apiCall.IsNoOp()
|
|
if noOp && !cq.inFlightEntities.Has(apiCall.UID()) {
|
|
// The call can be skipped, as it is a no-op and the old call is not in-flight.
|
|
if oldCallPresent {
|
|
cq.removePending(apiCall.UID())
|
|
}
|
|
apiCall.sendOnFinish(fmt.Errorf("call does not need to be executed because it has no effect: %w", fwk.ErrCallSkipped))
|
|
return fmt.Errorf("call does not need to be executed because it has no effect: %w", fwk.ErrCallSkipped)
|
|
}
|
|
|
|
objectUID := apiCall.UID()
|
|
cq.apiCalls[objectUID] = apiCall
|
|
if oldCallPresent {
|
|
// If another API call for this object is already present (i.e., is pending or in-flight),
|
|
// don't add this new call to the queue. The new call will be processed
|
|
// after the currently in-flight call is finalized.
|
|
return nil
|
|
}
|
|
cq.callsQueue.WriteOne(objectUID)
|
|
metrics.AsyncAPIPendingCalls.WithLabelValues(string(apiCall.CallType())).Inc()
|
|
cq.cond.Broadcast()
|
|
return nil
|
|
}
|
|
|
|
// removeFromQueue removes the objectUID from the queue and returns the recreated queue.
|
|
func removeFromQueue(queue *buffer.Ring[types.UID], objectUID types.UID) *buffer.Ring[types.UID] {
|
|
newQueue := buffer.NewRing[types.UID](buffer.RingOptions{
|
|
InitialSize: queue.Len(),
|
|
NormalSize: queue.Cap(),
|
|
})
|
|
for {
|
|
uid, ok := queue.ReadOne()
|
|
if !ok {
|
|
break
|
|
}
|
|
if uid != objectUID {
|
|
newQueue.WriteOne(uid)
|
|
}
|
|
}
|
|
return newQueue
|
|
}
|
|
|
|
// removePending removes a pending API call for the given objectUID from the queue and its associated data.
|
|
// This function is intended to be used on calls that have not yet been popped for execution
|
|
// (i.e., are not in-flight).
|
|
// Must be called under cq.lock.
|
|
func (cq *callQueue) removePending(objectUID types.UID) {
|
|
apiCall, ok := cq.apiCalls[objectUID]
|
|
if !ok {
|
|
return
|
|
}
|
|
delete(cq.apiCalls, objectUID)
|
|
if !cq.inFlightEntities.Has(objectUID) {
|
|
cq.callsQueue = *removeFromQueue(&cq.callsQueue, objectUID)
|
|
callType := string(apiCall.CallType())
|
|
metrics.AsyncAPIPendingCalls.WithLabelValues(callType).Dec()
|
|
}
|
|
}
|
|
|
|
// add adds a new apiCall to the queue.
|
|
// If an API call for the same object is already present in the queue,
|
|
// it tries to skip, overwrite, or merge the calls based on their relevance and type.
|
|
func (cq *callQueue) add(apiCall *queuedAPICall) error {
|
|
cq.lock.Lock()
|
|
defer cq.lock.Unlock()
|
|
|
|
apiCall.callID = cq.callIDCounter
|
|
cq.callIDCounter++
|
|
|
|
oldAPICall, ok := cq.apiCalls[apiCall.UID()]
|
|
if ok {
|
|
err := cq.merge(oldAPICall, apiCall)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return cq.enqueue(apiCall, ok)
|
|
}
|
|
|
|
// pop pops the first object UID from the queue and returns the corresponding API call details.
|
|
// Note: The caller must always call finalize() for the poped apiCall, otherwise it would result in the memory leak.
|
|
func (cq *callQueue) pop() (*queuedAPICall, error) {
|
|
cq.lock.Lock()
|
|
defer cq.lock.Unlock()
|
|
|
|
for cq.callsQueue.Len() == 0 {
|
|
if cq.closed {
|
|
return nil, nil
|
|
}
|
|
// Wait for an API call to become available.
|
|
cq.cond.Wait()
|
|
}
|
|
|
|
objectUID, ok := cq.callsQueue.ReadOne()
|
|
if !ok {
|
|
return nil, errors.New("api calls queue is empty")
|
|
}
|
|
apiCall, ok := cq.apiCalls[objectUID]
|
|
if !ok {
|
|
return nil, fmt.Errorf("object %s is not present in a map with API calls details", objectUID)
|
|
}
|
|
cq.inFlightEntities.Insert(objectUID)
|
|
callType := string(apiCall.CallType())
|
|
metrics.AsyncAPIPendingCalls.WithLabelValues(callType).Dec()
|
|
|
|
return apiCall, nil
|
|
}
|
|
|
|
// finalize handles a completed API call.
|
|
// If a new call for the same object arrived while the original was in-flight, it re-queues the object for processing.
|
|
// Otherwise, it removes the completed call's details from the queue.
|
|
// This method must be called after a call's execution is finished.
|
|
func (cq *callQueue) finalize(apiCall *queuedAPICall) {
|
|
cq.lock.Lock()
|
|
defer cq.lock.Unlock()
|
|
|
|
objectUID := apiCall.UID()
|
|
newAPICall := cq.apiCalls[objectUID]
|
|
if newAPICall.callID == apiCall.callID {
|
|
// The API call in the map hasn't changed, so we can remove it.
|
|
delete(cq.apiCalls, objectUID)
|
|
} else {
|
|
// The API call in the map has changed, so re-queue the object for the new call to be processed.
|
|
cq.callsQueue.WriteOne(objectUID)
|
|
callType := string(newAPICall.CallType())
|
|
metrics.AsyncAPIPendingCalls.WithLabelValues(callType).Inc()
|
|
cq.cond.Broadcast()
|
|
}
|
|
cq.inFlightEntities.Delete(objectUID)
|
|
}
|
|
|
|
// syncObject performs a two-way synchronization between the given object
|
|
// and a pending API call for that object, if one exists in the queue, and returns the synced object.
|
|
func (cq *callQueue) syncObject(obj metav1.Object) (metav1.Object, error) {
|
|
cq.lock.Lock()
|
|
defer cq.lock.Unlock()
|
|
|
|
objectUID := obj.GetUID()
|
|
apiCall, ok := cq.apiCalls[objectUID]
|
|
if !ok {
|
|
return obj, nil
|
|
}
|
|
|
|
syncedObj, err := apiCall.Sync(obj)
|
|
noOp := apiCall.IsNoOp()
|
|
if noOp && !cq.inFlightEntities.Has(apiCall.UID()) {
|
|
// The call can be removed, as the sync resulted in a no-op and the call is not in-flight.
|
|
cq.removePending(apiCall.UID())
|
|
apiCall.sendOnFinish(fmt.Errorf("call does not need to be executed because after sync it has no effect: %w", fwk.ErrCallSkipped))
|
|
}
|
|
|
|
return syncedObj, err
|
|
}
|
|
|
|
// close shuts down the callQueue.
|
|
func (cq *callQueue) close() {
|
|
cq.lock.Lock()
|
|
defer cq.lock.Unlock()
|
|
|
|
cq.closed = true
|
|
cq.cond.Broadcast()
|
|
}
|