Files
wehub-resource-sync e0e362d700
SDK Tests / changes (push) Successful in 2m29s
Real E2E Tests / changes (push) Successful in 2m29s
Deploy Docs Pages / build (push) Has been cancelled
Deploy Docs Pages / deploy (push) Has been cancelled
Real E2E Tests / JavaScript E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Python E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Java E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / C# E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Go E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Real E2E CI (push) Has been cancelled
SDK Tests / SDK CI (push) Has been cancelled
SDK Tests / CLI Tests (push) Has been cancelled
SDK Tests / Python SDK Quality (code-interpreter) (push) Has been cancelled
SDK Tests / Python SDK Quality (sandbox) (push) Has been cancelled
SDK Tests / Python SDK Tests (code-interpreter) (push) Has been cancelled
SDK Tests / JavaScript SDK Quality And Tests (code-interpreter) (push) Has been cancelled
SDK Tests / JavaScript SDK Quality And Tests (sandbox) (push) Has been cancelled
SDK Tests / Python SDK Tests (sandbox) (push) Has been cancelled
SDK Tests / CLI Quality (push) Has been cancelled
SDK Tests / Kotlin SDK Quality And Tests (sandbox) (push) Has been cancelled
SDK Tests / Kotlin SDK Quality And Tests (code-interpreter) (push) Has been cancelled
SDK Tests / C# SDK Quality And Tests (code-interpreter) (push) Has been cancelled
SDK Tests / C# SDK Quality And Tests (sandbox) (push) Has been cancelled
SDK Tests / Go SDK Quality And Tests (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:39:33 +08:00

962 lines
34 KiB
Go

// Copyright 2025 Alibaba Group Holding Ltd.
//
// 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 controller
import (
"context"
"crypto/sha256"
"encoding/hex"
gerrors "errors"
"fmt"
"os"
"sort"
"strconv"
"sync"
"time"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/equality"
"k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/fields"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
"k8s.io/apimachinery/pkg/util/intstr"
"k8s.io/apimachinery/pkg/util/json"
"k8s.io/client-go/rest"
"k8s.io/client-go/tools/record"
"k8s.io/client-go/util/retry"
"k8s.io/client-go/util/workqueue"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/builder"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/controller"
"sigs.k8s.io/controller-runtime/pkg/event"
"sigs.k8s.io/controller-runtime/pkg/handler"
logf "sigs.k8s.io/controller-runtime/pkg/log"
"sigs.k8s.io/controller-runtime/pkg/predicate"
"sigs.k8s.io/controller-runtime/pkg/reconcile"
sandboxv1alpha1 "github.com/alibaba/OpenSandbox/sandbox-k8s/apis/sandbox/v1alpha1"
"github.com/alibaba/OpenSandbox/sandbox-k8s/internal/controller/eviction"
"github.com/alibaba/OpenSandbox/sandbox-k8s/internal/controller/recycle"
"github.com/alibaba/OpenSandbox/sandbox-k8s/internal/utils"
controllerutils "github.com/alibaba/OpenSandbox/sandbox-k8s/internal/utils/controller"
"github.com/alibaba/OpenSandbox/sandbox-k8s/internal/utils/expectations"
"github.com/alibaba/OpenSandbox/sandbox-k8s/internal/utils/fieldindex"
)
const (
defaultRetryTime = 5 * time.Second
)
const (
LabelPoolName = "sandbox.opensandbox.io/pool-name"
LabelPoolRevision = "sandbox.opensandbox.io/pool-revision"
)
const (
defaultSyncSandboxAllocConcurrency = 256
envSyncSandboxAllocConcurrency = "SYNC_SANDBOX_ALLOC_CONCURRENCY"
defaultRecyclePodConcurrency = 64
envRecyclePodConcurrency = "RECYCLE_POD_CONCURRENCY"
)
var (
PoolScaleExpectations = expectations.NewScaleExpectations()
syncSandboxAllocConcurrency int
recyclePodConcurrency int
)
func init() {
syncSandboxAllocConcurrency = defaultSyncSandboxAllocConcurrency
if val := os.Getenv(envSyncSandboxAllocConcurrency); val != "" {
if n, err := strconv.Atoi(val); err == nil && n > 0 {
syncSandboxAllocConcurrency = n
}
}
recyclePodConcurrency = defaultRecyclePodConcurrency
if val := os.Getenv(envRecyclePodConcurrency); val != "" {
if n, err := strconv.Atoi(val); err == nil && n > 0 {
recyclePodConcurrency = n
}
}
}
// PoolReconciler reconciles a Pool object
type PoolReconciler struct {
client.Client
Scheme *runtime.Scheme
Recorder record.EventRecorder
Allocator Allocator
RestConfig *rest.Config
}
// +kubebuilder:rbac:groups=sandbox.opensandbox.io,resources=pools,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=sandbox.opensandbox.io,resources=pools/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=sandbox.opensandbox.io,resources=pools/finalizers,verbs=update
// +kubebuilder:rbac:groups=sandbox.opensandbox.io,resources=batchsandboxes,verbs=get;list;watch;patch
// +kubebuilder:rbac:groups=core,resources=pods,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=core,resources=pods/exec,verbs=create
// +kubebuilder:rbac:groups=core,resources=pods/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=core,resources=events,verbs=get;list;watch;create;update;patch;delete
func (r *PoolReconciler) Reconcile(ctx context.Context, req ctrl.Request) (result ctrl.Result, retErr error) {
log := logf.FromContext(ctx)
start := time.Now()
defer func() {
log.Info("Reconcile finished", "duration", time.Since(start).String(), "requeueAfter", result.RequeueAfter.String(), "error", retErr)
}()
// Fetch the Pool instance
pool := &sandboxv1alpha1.Pool{}
if err := r.Get(ctx, req.NamespacedName, pool); err != nil {
if errors.IsNotFound(err) {
// Pool resource not found, could have been deleted
controllerKey := req.NamespacedName.String()
PoolScaleExpectations.DeleteExpectations(controllerKey)
r.Allocator.ClearPoolAllocation(ctx, req.Namespace, req.Name)
log.Info("Pool resource not found, cleaned up scale expectations", "pool", controllerKey)
return ctrl.Result{}, nil
}
// Error reading the object - requeue the request
log.Error(err, "Failed to get Pool")
return ctrl.Result{}, err
}
if !pool.DeletionTimestamp.IsZero() {
controllerKey := controllerutils.GetControllerKey(pool)
PoolScaleExpectations.DeleteExpectations(controllerKey)
r.Allocator.ClearPoolAllocation(ctx, req.Namespace, req.Name)
log.Info("Pool resource is being deleted, cleaned up scale expectations", "pool", controllerKey)
return ctrl.Result{}, nil
}
// List all pods of the pool
podList := &corev1.PodList{}
if err := r.List(ctx, podList, &client.ListOptions{
Namespace: pool.Namespace,
FieldSelector: fields.SelectorFromSet(fields.Set{fieldindex.IndexNameForOwnerRefUID: string(pool.UID)}),
}); err != nil {
log.Error(err, "Failed to list pods")
return reconcile.Result{}, err
}
pods := make([]*corev1.Pod, 0, len(podList.Items))
for i := range podList.Items {
pod := podList.Items[i]
PoolScaleExpectations.ObserveScale(controllerutils.GetControllerKey(pool), expectations.Create, pod.Name)
if pod.DeletionTimestamp.IsZero() {
pods = append(pods, &pod)
}
}
// List all batch sandboxes ref to the pool
batchSandboxList := &sandboxv1alpha1.BatchSandboxList{}
if err := r.List(ctx, batchSandboxList, &client.ListOptions{
Namespace: pool.Namespace,
FieldSelector: fields.SelectorFromSet(fields.Set{fieldindex.IndexNameForPoolRef: pool.Name}),
}); err != nil {
log.Error(err, "Failed to list batch sandboxes")
return reconcile.Result{}, err
}
batchSandboxes := make([]*sandboxv1alpha1.BatchSandbox, 0, len(batchSandboxList.Items))
for i := range batchSandboxList.Items {
batchSandbox := batchSandboxList.Items[i]
if batchSandbox.Spec.Template != nil {
continue
}
batchSandboxes = append(batchSandboxes, &batchSandbox)
}
log.Info("Pool reconcile", "pool", pool.Name, "pods", len(pods), "batchSandboxes", len(batchSandboxes))
return r.reconcilePool(ctx, pool, batchSandboxes, pods)
}
// reconcilePool contains the main reconciliation logic
func (r *PoolReconciler) reconcilePool(ctx context.Context, pool *sandboxv1alpha1.Pool, batchSandboxes []*sandboxv1alpha1.BatchSandbox, pods []*corev1.Pod) (ctrl.Result, error) {
var result ctrl.Result
err := retry.RetryOnConflict(retry.DefaultBackoff, func() error {
// 1. Get latest Pool CR
latestPool := &sandboxv1alpha1.Pool{}
if err := r.Get(ctx, client.ObjectKeyFromObject(pool), latestPool); err != nil {
return err
}
// 2. Handle pod eviction
schedulePods, evictionErr := r.handleEviction(ctx, latestPool, pods)
if schedulePods == nil {
return evictionErr
}
// 3. Schedule sandbox (compute + persist + sync)
schedResult, err := r.scheduleSandbox(ctx, latestPool, batchSandboxes, schedulePods)
if err != nil {
return err
}
// Requeue if there are pending sandboxes waiting for scheduling
if schedResult.SupplyCnt > 0 {
result = ctrl.Result{RequeueAfter: defaultRetryTime}
}
// 4. Handle pool upgrade
updateResult, err := r.updatePool(ctx, latestPool, schedulePods, schedResult.IdlePods)
if err != nil {
return err
}
// 5. Handle pool scale
toDeletePods := append(updateResult.ToDeletePods, schedResult.ToDelete...)
args := &scaleArgs{
updateRevision: updateResult.UpdateRevision,
pods: schedulePods,
totalPodCnt: int32(len(pods)),
allocatedCnt: int32(len(schedResult.LatestAllocation)),
idlePods: updateResult.IdlePods,
toDeletePods: toDeletePods,
supplyCnt: schedResult.SupplyCnt + updateResult.SupplyUpdateRevision,
}
if err := r.scalePool(ctx, latestPool, args); err != nil {
return err
}
// 6. Update pool status
if err := r.updatePoolStatus(ctx, updateResult.UpdateRevision, latestPool, pods, schedulePods, schedResult.LatestAllocation); err != nil {
return err
}
if evictionErr != nil {
return evictionErr
}
return nil
})
return result, err
}
func (r *PoolReconciler) calculateRevision(pool *sandboxv1alpha1.Pool) (string, error) {
template, err := json.Marshal(pool.Spec.Template)
if err != nil {
return "", err
}
revision := sha256.Sum256(template)
return hex.EncodeToString(revision[:8]), nil
}
// SetupWithManager sets up the controller with the Manager.
// Todo pod deletion expectations
func (r *PoolReconciler) SetupWithManager(mgr ctrl.Manager, maxConcurrentReconciles int) error {
filterBatchSandbox := predicate.Funcs{
CreateFunc: func(e event.CreateEvent) bool {
bsb, ok := e.Object.(*sandboxv1alpha1.BatchSandbox)
if !ok {
return false
}
return bsb.Spec.PoolRef != ""
},
UpdateFunc: func(e event.UpdateEvent) bool {
oldObj, okOld := e.ObjectOld.(*sandboxv1alpha1.BatchSandbox)
newObj, okNew := e.ObjectNew.(*sandboxv1alpha1.BatchSandbox)
if !okOld || !okNew {
return false
}
if newObj.Spec.PoolRef == "" {
return false
}
oldVal := oldObj.Annotations[AnnoAllocReleaseKey]
newVal := newObj.Annotations[AnnoAllocReleaseKey]
if oldVal != newVal {
return true
}
if oldObj.Spec.Replicas != newObj.Spec.Replicas {
return true
}
// Trigger reconcile when sandbox enters terminating state (DeletionTimestamp is set).
if oldObj.DeletionTimestamp.IsZero() && !newObj.DeletionTimestamp.IsZero() {
return true
}
return false
},
DeleteFunc: func(e event.DeleteEvent) bool {
bsb, ok := e.Object.(*sandboxv1alpha1.BatchSandbox)
if !ok {
return false
}
return bsb.Spec.PoolRef != ""
},
GenericFunc: func(e event.GenericEvent) bool {
bsb, ok := e.Object.(*sandboxv1alpha1.BatchSandbox)
if !ok {
return false
}
return bsb.Spec.PoolRef != ""
},
}
findPoolForBatchSandbox := func(ctx context.Context, obj client.Object) []reconcile.Request {
log := logf.FromContext(ctx)
batchSandbox, ok := obj.(*sandboxv1alpha1.BatchSandbox)
if !ok {
log.Error(nil, "Invalid object type, expected BatchSandbox")
return nil
}
return []reconcile.Request{
{
NamespacedName: types.NamespacedName{
Namespace: batchSandbox.Namespace,
Name: batchSandbox.Spec.PoolRef,
},
},
}
}
filterBatchSandboxDetached := predicate.Funcs{
UpdateFunc: func(e event.UpdateEvent) bool {
oldObj, okOld := e.ObjectOld.(*sandboxv1alpha1.BatchSandbox)
newObj, okNew := e.ObjectNew.(*sandboxv1alpha1.BatchSandbox)
if !okOld || !okNew {
return false
}
return oldObj.Spec.PoolRef != "" && newObj.Spec.PoolRef == ""
},
}
enqueueOldPoolForDetachedBatchSandbox := handler.Funcs{
UpdateFunc: func(_ context.Context, e event.UpdateEvent, q workqueue.TypedRateLimitingInterface[reconcile.Request]) {
oldObj, ok := e.ObjectOld.(*sandboxv1alpha1.BatchSandbox)
if !ok || oldObj.Spec.PoolRef == "" {
return
}
q.Add(reconcile.Request{
NamespacedName: types.NamespacedName{
Namespace: oldObj.Namespace,
Name: oldObj.Spec.PoolRef,
},
})
},
}
return ctrl.NewControllerManagedBy(mgr).
For(&sandboxv1alpha1.Pool{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})).
Owns(&corev1.Pod{}).
Watches(
&sandboxv1alpha1.BatchSandbox{},
handler.EnqueueRequestsFromMapFunc(findPoolForBatchSandbox),
builder.WithPredicates(filterBatchSandbox),
).
Watches(
&sandboxv1alpha1.BatchSandbox{},
enqueueOldPoolForDetachedBatchSandbox,
builder.WithPredicates(filterBatchSandboxDetached),
).
Named("pool").
WithOptions(controller.Options{MaxConcurrentReconciles: maxConcurrentReconciles}).
Complete(r)
}
func (r *PoolReconciler) doAllocate(ctx context.Context, pool *sandboxv1alpha1.Pool, batchSandboxes []*sandboxv1alpha1.BatchSandbox, pods []*corev1.Pod, toAllocate map[string][]string) error {
// 1. Compute latest allocated pods per sandbox (merge current + newly allocated).
toSyncMap := r.getLatestAllocated(ctx, pool, batchSandboxes, toAllocate)
// 2. Concurrently sync each sandbox's Allocated annotation (AddFinalizer is called inside SyncSandboxAllocation).
return r.syncSandboxConcurrently(ctx, batchSandboxes, toSyncMap, r.Allocator.SyncSandboxAllocation, "allocated")
}
// getLatestAllocated computes the latest allocated pods for each sandbox by merging current allocation with new pods to allocate.
func (r *PoolReconciler) getLatestAllocated(ctx context.Context, pool *sandboxv1alpha1.Pool, batchSandboxes []*sandboxv1alpha1.BatchSandbox, toAllocate map[string][]string) map[string][]string {
log := logf.FromContext(ctx)
sandboxByName := make(map[string]*sandboxv1alpha1.BatchSandbox, len(batchSandboxes))
for _, bs := range batchSandboxes {
sandboxByName[bs.Name] = bs
}
toSyncMap := make(map[string][]string, len(toAllocate))
for sandboxName, allocPods := range toAllocate {
if len(allocPods) == 0 {
continue
}
sandbox, ok := sandboxByName[sandboxName]
if !ok {
log.Error(nil, "Sandbox not found for allocate", "sandbox", sandboxName)
continue
}
currentAllocated, err := r.Allocator.GetSandboxAllocation(ctx, sandbox)
if err != nil {
log.Error(err, "Failed to get sandbox allocated", "sandbox", sandboxName)
continue
}
toSyncMap[sandboxName] = append(currentAllocated, allocPods...)
}
return toSyncMap
}
// syncSandboxConcurrently syncs allocation or released state for each sandbox concurrently.
// Each sandbox is an independent resource, so concurrent writes are safe.
func (r *PoolReconciler) syncSandboxConcurrently(ctx context.Context, batchSandboxes []*sandboxv1alpha1.BatchSandbox, toSyncMap map[string][]string, syncFn func(context.Context, *sandboxv1alpha1.BatchSandbox, []string) error, label string) error {
log := logf.FromContext(ctx)
sandboxByName := make(map[string]*sandboxv1alpha1.BatchSandbox, len(batchSandboxes))
for _, bs := range batchSandboxes {
sandboxByName[bs.Name] = bs
}
errCh := make(chan error, len(toSyncMap))
sem := make(chan struct{}, syncSandboxAllocConcurrency)
var wg sync.WaitGroup
for sandboxName, pods := range toSyncMap {
sandbox, ok := sandboxByName[sandboxName]
if !ok {
log.Error(nil, "Sandbox not found for sync "+label, "sandbox", sandboxName)
continue
}
wg.Add(1)
go func() {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
if err := syncFn(ctx, sandbox, pods); err != nil {
log.Error(err, "Failed to sync sandbox "+label, "sandbox", sandbox.Name)
errCh <- err
}
}()
}
wg.Wait()
close(errCh)
var errs []error
for err := range errCh {
errs = append(errs, err)
}
return gerrors.Join(errs...)
}
func (r *PoolReconciler) doRecycle(ctx context.Context, pool *sandboxv1alpha1.Pool, batchSandboxes []*sandboxv1alpha1.BatchSandbox, pods []*corev1.Pod, toRecycle map[string][]string) (map[string][]string, []string, error) {
if len(toRecycle) == 0 {
return nil, nil, nil
}
handler, err := recycle.NewHandler(r.Client, r.RestConfig, pool)
if err != nil {
return nil, nil, fmt.Errorf("failed to get recycle handler for pool %s: %w", pool.Name, err)
}
results := r.runRecycleTasks(ctx, pool, pods, toRecycle, handler)
return collectRecycleResults(ctx, results)
}
type recycleResult struct {
sandboxName string
podName string
status *recycle.Status
err error
}
// runRecycleTasks executes TryRecycle concurrently for each (sandbox, pod) pair and
// returns one result per task.
func (r *PoolReconciler) runRecycleTasks(ctx context.Context, pool *sandboxv1alpha1.Pool, pods []*corev1.Pod, toRecycle map[string][]string, handler recycle.Handler) []recycleResult {
podByName := make(map[string]*corev1.Pod, len(pods))
for _, p := range pods {
podByName[p.Name] = p
}
// Flatten the map into an ordered slice so goroutines can write by index.
type task struct {
sandboxName string
podName string
}
var tasks []task
for sandboxName, podNames := range toRecycle {
for _, podName := range podNames {
tasks = append(tasks, task{sandboxName: sandboxName, podName: podName})
}
}
// Results are written by index so each goroutine writes to a unique slot without synchronization.
results := make([]recycleResult, len(tasks))
sem := make(chan struct{}, recyclePodConcurrency)
var wg sync.WaitGroup
for idx, task := range tasks {
localIdx, localTask := idx, task
wg.Add(1)
go func() {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
status, err := handler.TryRecycle(ctx, pool, podByName[localTask.podName], &recycle.Spec{ID: localTask.sandboxName})
results[localIdx] = recycleResult{sandboxName: localTask.sandboxName, podName: localTask.podName, status: status, err: err}
}()
}
wg.Wait()
return results
}
// collectRecycleResults aggregates per-task results into the succeed map and delete list.
func collectRecycleResults(ctx context.Context, results []recycleResult) (map[string][]string, []string, error) {
log := logf.FromContext(ctx)
succeedMap := make(map[string][]string)
var toDeletePods []string
var errs []error
for _, res := range results {
if res.err != nil {
log.Error(res.err, "Failed to recycle pod", "pod", res.podName, "sandbox", res.sandboxName)
errs = append(errs, res.err)
continue
}
if res.status.State == recycle.StateSucceeded {
succeedMap[res.sandboxName] = append(succeedMap[res.sandboxName], res.podName)
}
if res.status.NeedDelete {
toDeletePods = append(toDeletePods, res.podName)
}
}
return succeedMap, toDeletePods, gerrors.Join(errs...)
}
// doRelease runs the recycle operation for pods to be returned to the pool,
// then persists the released state to each sandbox's annotation.
func (r *PoolReconciler) doRelease(ctx context.Context, pool *sandboxv1alpha1.Pool, batchSandboxes []*sandboxv1alpha1.BatchSandbox, pods []*corev1.Pod, toRelease map[string][]string) ([]string, error) {
log := logf.FromContext(ctx)
// 1. Recycle pods.
succeedMap, toDeletePods, err := r.doRecycle(ctx, pool, batchSandboxes, pods, toRelease)
if err != nil {
log.Error(err, "Some errors occurred during recycle")
r.Recorder.Eventf(pool, corev1.EventTypeWarning, EventReasonFailedRecyclePod, "Failed to recycle some pods: %v", err)
}
// Emit recycle success events.
var allRecycled []string
for _, recycledPods := range succeedMap {
allRecycled = append(allRecycled, recycledPods...)
}
if len(allRecycled) > 0 {
r.Recorder.Eventf(pool, corev1.EventTypeNormal, EventReasonPodRecycled, "Recycled %d pod(s): %v", len(allRecycled), allRecycled)
}
// 2. Compute latest released pods per sandbox (merge current + recycle-succeeded).
// Also collect orphan pods whose sandboxes no longer exist.
toSyncMap, orphanPods := r.getLatestReleased(ctx, batchSandboxes, succeedMap)
// 3. Concurrently sync each sandbox's Released annotation.
syncErr := r.syncSandboxConcurrently(ctx, batchSandboxes, toSyncMap, r.Allocator.SyncSandboxReleased, "released")
if syncErr != nil {
log.Error(syncErr, "Failed to sync released")
}
// 4. Release in-memory allocations for orphan pods directly (no annotation to persist).
if len(orphanPods) > 0 {
r.Allocator.ReleasePodsAllocation(ctx, pool.Namespace, pool.Name, orphanPods)
}
return toDeletePods, gerrors.Join(err, syncErr)
}
// getLatestReleased computes the latest released pods for each sandbox by merging current released with recycle-succeeded pods.
// It also returns orphanPods: pods from succeedMap whose sandbox no longer exists and should be released directly by the caller.
func (r *PoolReconciler) getLatestReleased(ctx context.Context, batchSandboxes []*sandboxv1alpha1.BatchSandbox, succeedMap map[string][]string) (map[string][]string, []string) {
log := logf.FromContext(ctx)
sandboxByName := make(map[string]*sandboxv1alpha1.BatchSandbox, len(batchSandboxes))
for _, bs := range batchSandboxes {
sandboxByName[bs.Name] = bs
}
toSyncMap := make(map[string][]string, len(succeedMap))
orphanPods := make([]string, 0)
for sandboxName, succeedPods := range succeedMap {
if len(succeedPods) == 0 {
continue
}
sandbox, ok := sandboxByName[sandboxName]
if !ok {
// Orphan sandbox: deleted before recycle completed. Collect its pods for direct release.
log.Info("GC: sandbox not found for recycle result, collecting orphan pods", "sandbox", sandboxName, "pods", succeedPods)
orphanPods = append(orphanPods, succeedPods...)
continue
}
currentReleased, err := r.Allocator.GetSandboxReleased(ctx, sandbox)
if err != nil {
log.Error(err, "Failed to get sandbox released", "sandbox", sandboxName)
continue
}
toSyncMap[sandboxName] = append(currentReleased, succeedPods...)
}
return toSyncMap, orphanPods
}
func (r *PoolReconciler) scheduleSandbox(ctx context.Context, pool *sandboxv1alpha1.Pool, batchSandboxes []*sandboxv1alpha1.BatchSandbox, pods []*corev1.Pod) (*ScheduleResult, error) {
log := logf.FromContext(ctx)
// 1. Compute scheduling actions.
spec := &AllocSpec{
Sandboxes: batchSandboxes,
Pool: pool,
Pods: pods,
}
allocAction, err := r.Allocator.Schedule(ctx, spec)
if err != nil {
r.Recorder.Eventf(pool, corev1.EventTypeWarning, EventReasonAllocationFailed, "Failed to schedule sandboxes: %v", err)
return nil, err
}
log.Info("Allocate action", "pool", pool.Name, "toAllocate", allocAction.ToAllocate, "toRelease", allocAction.ToRelease)
// 2. Execute scheduling actions.
// 2.1 Execute ToAllocate / update in-memory store.
err = r.doAllocate(ctx, pool, batchSandboxes, pods, allocAction.ToAllocate)
if err != nil {
return nil, err
}
// Emit allocation events.
sandboxByName := make(map[string]*sandboxv1alpha1.BatchSandbox, len(batchSandboxes))
for _, bs := range batchSandboxes {
sandboxByName[bs.Name] = bs
}
for sandboxName, allocPods := range allocAction.ToAllocate {
if len(allocPods) == 0 {
continue
}
r.Recorder.Eventf(pool, corev1.EventTypeNormal, EventReasonAllocationSucceeded,
"Allocated %d pod(s) to sandbox %s: %v", len(allocPods), sandboxName, allocPods)
if sbx, ok := sandboxByName[sandboxName]; ok {
r.Recorder.Eventf(sbx, corev1.EventTypeNormal, EventReasonScheduled,
"Successfully assigned %d pod(s) from pool %s: %v", len(allocPods), pool.Name, allocPods)
}
}
// 2.2 Execute ToRelease / release in-memory store.
toDeletePods, err := r.doRelease(ctx, pool, batchSandboxes, pods, allocAction.ToRelease)
if err != nil {
return nil, err
}
// 3. Return schedule result
latestAllocation, err := r.Allocator.GetPoolAllocation(ctx, pool)
if err != nil {
return nil, err
}
idlePods := make([]string, 0)
for _, pod := range pods {
if _, ok := latestAllocation[pod.Name]; !ok {
idlePods = append(idlePods, pod.Name)
}
}
result := &ScheduleResult{
LatestAllocation: latestAllocation,
IdlePods: idlePods,
ToDelete: toDeletePods,
SupplyCnt: allocAction.PodSupplement,
}
log.Info("Schedule result", "pool", pool.Name, "toDeletePods", toDeletePods, "supplyCnt", allocAction.PodSupplement)
return result, nil
}
func (r *PoolReconciler) updatePool(ctx context.Context, pool *sandboxv1alpha1.Pool, pods []*corev1.Pod, idlePods []string) (*UpdateResult, error) {
updateRevision, err := r.calculateRevision(pool)
if err != nil {
return nil, err
}
strategy := NewPoolUpdateStrategy(pool)
result := strategy.Compute(ctx, updateRevision, pods, idlePods)
result.UpdateRevision = updateRevision
if len(result.ToDeletePods) > 0 {
r.Recorder.Eventf(pool, corev1.EventTypeNormal, EventReasonPodUpdated,
"Rolling update: deleting %d pod(s) for revision %s: %v", len(result.ToDeletePods), updateRevision, result.ToDeletePods)
}
return result, nil
}
type scaleArgs struct {
updateRevision string
pods []*corev1.Pod
totalPodCnt int32 // all pods including evicting ones, for PoolMax enforcement
allocatedCnt int32
supplyCnt int32 // to create
idlePods []string
toDeletePods []string
}
type ScheduleResult struct {
// LatestAllocation is the most recent pod-to-sandbox allocation map.
LatestAllocation map[string]string
// IdlePods contains pods that are not currently allocated to any sandbox.
IdlePods []string
// ToDelete contains pods that the recycle handler has decided to delete
// (e.g. direct deletion or restart failure fallback).
ToDelete []string
// SupplyCnt is the number of additional pods the allocator needs but are not yet available.
SupplyCnt int32
}
type UpdateResult struct {
UpdateRevision string
IdlePods []string
ToDeletePods []string
// Supply Pods with update revision
SupplyUpdateRevision int32
}
func (r *PoolReconciler) scalePool(ctx context.Context, pool *sandboxv1alpha1.Pool, args *scaleArgs) error {
log := logf.FromContext(ctx)
errs := make([]error, 0)
pods := args.pods
if satisfied, unsatisfiedDuration, dirtyPods := PoolScaleExpectations.SatisfiedExpectations(controllerutils.GetControllerKey(pool)); !satisfied {
if unsatisfiedDuration >= expectations.ExpectationTimeout {
log.Info("Pool scale expectations timed out, clearing stale expectations",
"unsatisfiedDuration", unsatisfiedDuration, "dirtyPods", dirtyPods)
PoolScaleExpectations.DeleteExpectations(controllerutils.GetControllerKey(pool))
} else {
log.Info("Pool scale is not ready, requeue", "unsatisfiedDuration", unsatisfiedDuration, "dirtyPods", dirtyPods)
return fmt.Errorf("pool scale is not ready, %v", pool.Name)
}
}
schedulableCnt := int32(len(args.pods))
totalPodCnt := args.totalPodCnt
allocatedCnt := args.allocatedCnt
supplyCnt := args.supplyCnt
toDeletePods := args.toDeletePods
bufferCnt := schedulableCnt - allocatedCnt
// Calculate desired buffer cnt.
desiredBufferCnt := bufferCnt
if bufferCnt < pool.Spec.CapacitySpec.BufferMin || bufferCnt > pool.Spec.CapacitySpec.BufferMax {
desiredBufferCnt = (pool.Spec.CapacitySpec.BufferMin + pool.Spec.CapacitySpec.BufferMax) / 2
}
// Calculate desired schedulable cnt.
desiredSchedulableCnt := max(allocatedCnt+supplyCnt+desiredBufferCnt, pool.Spec.CapacitySpec.PoolMin)
// Enforce PoolMax: limit new pods based on total running pods (including evicting).
maxNewPods := max(pool.Spec.CapacitySpec.PoolMax-totalPodCnt, 0)
log.Info("Scale pool decision", "pool", pool.Name,
"totalPodCnt", totalPodCnt, "schedulableCnt", schedulableCnt,
"allocatedCnt", allocatedCnt, "bufferCnt", bufferCnt,
"desiredBufferCnt", desiredBufferCnt, "supplyCnt", supplyCnt,
"desiredSchedulableCnt", desiredSchedulableCnt, "maxNewPods", maxNewPods,
"toDeletePods", len(toDeletePods), "idlePods", len(args.idlePods))
// Scale-up: create new pods if needed and allowed by PoolMax
if desiredSchedulableCnt > schedulableCnt && maxNewPods > 0 {
createCnt := min(desiredSchedulableCnt-schedulableCnt, maxNewPods)
scaleMaxUnavailable := r.getScaleMaxUnavailable(pool, desiredSchedulableCnt)
notReadyCnt := r.countNotReadyPods(pods)
limitedCreateCnt := scaleMaxUnavailable - notReadyCnt
createCnt = max(0, min(createCnt, limitedCreateCnt))
if createCnt > 0 {
log.Info("Scaling up pool with constraint", "pool", pool.Name,
"createCnt", createCnt, "scaleMaxUnavailable", scaleMaxUnavailable,
"notReadyCnt", notReadyCnt, "desiredSchedulableCnt", desiredSchedulableCnt, "limitedCreateCnt", limitedCreateCnt)
for range createCnt {
if err := r.createPoolPod(ctx, pool, args.updateRevision); err != nil {
log.Error(err, "Failed to create pool pod")
errs = append(errs, err)
}
}
}
}
// Scale-down: delete redundant or excess pods
scaleIn := int32(0)
if desiredSchedulableCnt < schedulableCnt {
scaleIn = schedulableCnt - desiredSchedulableCnt
}
if scaleIn > 0 || len(toDeletePods) > 0 {
podsToDelete := r.pickPodsToDelete(pods, args.idlePods, args.toDeletePods, scaleIn)
log.Info("Scaling down pool", "pool", pool.Name, "scaleIn", scaleIn, "toDeletePods", len(toDeletePods), "podsToDelete", len(podsToDelete))
for _, pod := range podsToDelete {
log.Info("Deleting pool pod", "pool", pool.Name, "pod", pod.Name)
if err := r.Delete(ctx, pod); err != nil {
log.Error(err, "Failed to delete pool pod", "pod", pod.Name)
r.Recorder.Eventf(pool, corev1.EventTypeWarning, EventReasonFailedDelete, "Failed to delete pool pod %s: %v", pod.Name, err)
errs = append(errs, err)
} else {
r.Recorder.Eventf(pool, corev1.EventTypeNormal, EventReasonSuccessfulDelete, "Deleted pool pod %s (scale-down)", pod.Name)
}
}
}
return gerrors.Join(errs...)
}
func (r *PoolReconciler) updatePoolStatus(ctx context.Context, updateRevision string, pool *sandboxv1alpha1.Pool, pods []*corev1.Pod, schedulePods []*corev1.Pod, podAllocation map[string]string) error {
oldStatus := pool.Status.DeepCopy()
availableCnt := int32(0)
for _, pod := range schedulePods {
if _, ok := podAllocation[pod.Name]; ok {
continue
}
if !utils.IsPodReady(pod) {
continue
}
availableCnt++
}
updatedCnt := int32(0)
for _, pod := range pods {
if pod.Labels[LabelPoolRevision] == updateRevision {
updatedCnt++
}
}
pool.Status.ObservedGeneration = pool.Generation
pool.Status.Total = int32(len(pods))
pool.Status.Allocated = int32(len(podAllocation))
pool.Status.Available = availableCnt
pool.Status.Revision = updateRevision
pool.Status.Updated = updatedCnt
if equality.Semantic.DeepEqual(*oldStatus, pool.Status) {
return nil
}
log := logf.FromContext(ctx)
log.Info("Update pool status", "ObservedGeneration", pool.Status.ObservedGeneration, "Total", pool.Status.Total,
"Allocated", pool.Status.Allocated, "Available", pool.Status.Available, "Revision", pool.Status.Revision, "Updated", pool.Status.Updated)
if err := r.Status().Update(ctx, pool); err != nil {
return err
}
return nil
}
func (r *PoolReconciler) pickPodsToDelete(pods []*corev1.Pod, idlePodNames []string, toDeletePodNames []string, scaleIn int32) []*corev1.Pod {
podMap := make(map[string]*corev1.Pod)
for _, pod := range pods {
podMap[pod.Name] = pod
}
var podsToDelete []*corev1.Pod
for _, name := range toDeletePodNames {
pod, ok := podMap[name]
if !ok {
continue
}
podsToDelete = append(podsToDelete, pod)
}
var idlePods []*corev1.Pod
for _, name := range idlePodNames {
pod, ok := podMap[name]
if !ok {
continue
}
idlePods = append(idlePods, pod)
}
sort.Slice(idlePods, func(i, j int) bool {
return idlePods[i].CreationTimestamp.Before(&idlePods[j].CreationTimestamp)
})
for _, pod := range idlePods {
if scaleIn <= 0 {
break
}
if pod.DeletionTimestamp == nil {
podsToDelete = append(podsToDelete, pod)
}
scaleIn -= 1
}
return podsToDelete
}
// getScaleMaxUnavailable returns the resolved maxUnavailable value.
// If not specified, defaults to 25% of desiredTotal.
// Minimum return value is 1 to ensure scaling progress.
func (r *PoolReconciler) getScaleMaxUnavailable(pool *sandboxv1alpha1.Pool, desiredTotal int32) int32 {
defaultPercentage := intstr.FromString("25%")
maxUnavailable := &defaultPercentage
if pool.Spec.ScaleStrategy != nil && pool.Spec.ScaleStrategy.MaxUnavailable != nil {
maxUnavailable = pool.Spec.ScaleStrategy.MaxUnavailable
}
result, err := intstr.GetScaledValueFromIntOrPercent(maxUnavailable, int(desiredTotal), true)
if err != nil || result < 1 {
result = 1
}
return int32(result)
}
// countNotReadyPods returns the count of pods that are not ready.
// A pod is considered not ready if it doesn't have a Ready condition
// with status True.
func (r *PoolReconciler) countNotReadyPods(pods []*corev1.Pod) int32 {
var count int32
for _, pod := range pods {
if !utils.IsPodReady(pod) {
count++
}
}
return count
}
func (r *PoolReconciler) createPoolPod(ctx context.Context, pool *sandboxv1alpha1.Pool, updateRevision string) error {
log := logf.FromContext(ctx)
pod, err := utils.GetPodFromTemplate(pool.Spec.Template, pool, metav1.NewControllerRef(pool, sandboxv1alpha1.SchemeBuilder.GroupVersion.WithKind("Pool")))
if err != nil {
return err
}
pod.Namespace = pool.Namespace
pod.Name = ""
pod.GenerateName = pool.Name + "-"
pod.Labels[LabelPoolName] = pool.Name
pod.Labels[LabelPoolRevision] = updateRevision
if err := ctrl.SetControllerReference(pool, pod, r.Scheme); err != nil {
return err
}
if err := r.Create(ctx, pod); err != nil {
r.Recorder.Eventf(pool, corev1.EventTypeWarning, EventReasonFailedCreate, "Failed to create pool pod: %v", err)
return err
}
PoolScaleExpectations.ExpectScale(controllerutils.GetControllerKey(pool), expectations.Create, pod.Name)
log.Info("Created pool pod", "pool", pool.Name, "pod", pod.Name, "revision", updateRevision)
r.Recorder.Eventf(pool, corev1.EventTypeNormal, EventReasonSuccessfulCreate, "Created pool pod: %v", pod.Name)
return nil
}
// handleEviction fetches the current allocation, evicts idle pods marked for eviction,
// and returns the schedulable pods (excluding evicting idle pods) along with any eviction error.
// Eviction errors are non-fatal: they are returned to trigger a requeue but do not block the current reconcile.
func (r *PoolReconciler) handleEviction(ctx context.Context, pool *sandboxv1alpha1.Pool, pods []*corev1.Pod) ([]*corev1.Pod, error) {
log := logf.FromContext(ctx)
podAllocation, err := r.Allocator.GetPoolAllocation(ctx, pool)
if err != nil {
log.Error(err, "Failed to get pool allocation")
return nil, err
}
handler := eviction.NewEvictionHandler(ctx, r.Client, pool)
var evictionErrs []error
filtered := make([]*corev1.Pod, 0, len(pods))
for _, pod := range pods {
if !handler.NeedsEviction(pod) {
filtered = append(filtered, pod)
continue
}
if sandboxName, allocated := podAllocation[pod.Name]; allocated {
log.V(1).Info("Skipping eviction for allocated pod", "pod", pod.Name, "sandbox", sandboxName)
filtered = append(filtered, pod)
continue
}
// Idle pod marked for eviction: evict and exclude from scheduling
log.Info("Evicting idle pool pod", "pool", pool.Name, "pod", pod.Name)
if err := handler.Evict(ctx, pod); err != nil {
log.Error(err, "Failed to evict pod", "pod", pod.Name)
evictionErrs = append(evictionErrs, fmt.Errorf("failed to evict pod %s: %w", pod.Name, err))
} else {
r.Recorder.Eventf(pool, corev1.EventTypeNormal, EventReasonPodEvicted, "Evicted idle pod: %s", pod.Name)
}
}
return filtered, gerrors.Join(evictionErrs...)
}