diff --git a/internal/controller/pod.go b/internal/controller/pod.go index d8663c5c..6adca279 100644 --- a/internal/controller/pod.go +++ b/internal/controller/pod.go @@ -37,6 +37,12 @@ var ( otelHTTPClient = &http.Client{Transport: otelhttp.NewTransport(http.DefaultTransport)} ) +// defaultAssignTokenTTL is the cluster-wide fallback the controller passes to +// Assignment.AssignTokenTTL when the tenant has not set a per-assignment +// override. A future cluster flag can replace this constant without changing +// the resolver call site. +const defaultAssignTokenTTL = 7 * 24 * time.Hour + func (ctrl *Controller) assignPod(ctx context.Context, fn *skipper.Assignment) (instance *skipper.Instance, err error) { ctx, span := telemetry.Trace(ctx, "controller.assign_pod") defer span.End() @@ -103,7 +109,7 @@ GET_UNASSIGNED_POD: }() assignURL := "http://" + net.JoinHostPort(assignedPod.Status.PodIP, port) + ctrl.config.AssignPath - assignCtx, cancel := context.WithTimeout(ctx, ctrl.config.AssignTimeout) + assignCtx, cancel := context.WithTimeout(ctx, fn.AssignTimeout(ctrl.config.AssignTimeout)) defer cancel() now := time.Now() @@ -111,7 +117,7 @@ GET_UNASSIGNED_POD: token.SetSubject(fn.GetTenant()) token.SetIssuedAt(now) token.SetNotBefore(now) - token.SetExpiration(now.Add(7 * 24 * time.Hour)) + token.SetExpiration(now.Add(fn.AssignTokenTTL(defaultAssignTokenTTL))) var req *http.Request req, err = http.NewRequestWithContext(assignCtx, http.MethodPost, assignURL, nil) diff --git a/internal/controller/policy_wiring_test.go b/internal/controller/policy_wiring_test.go new file mode 100644 index 00000000..1478492a --- /dev/null +++ b/internal/controller/policy_wiring_test.go @@ -0,0 +1,282 @@ +package controller + +import ( + "testing" + "time" + + "github.com/gadget-inc/skipper/internal/skipper" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/durationpb" + "google.golang.org/protobuf/types/known/timestamppb" + "gotest.tools/v3/assert" +) + +// withHeartbeat returns a fresh heartbeat carrying the given assignment and a +// timestamp slightly in the past, so the heartbeat-timeout branch of +// calculateDesiredInstances depends entirely on the timeout knob under test. +func withHeartbeat(fn *skipper.Assignment, when time.Time, inFlight uint32) *skipper.Heartbeat { + hb := &skipper.Heartbeat{} + hb.SetAssignment(fn) + hb.SetTimestamp(timestamppb.New(when)) + hb.SetInFlightRequests(inFlight) + return hb +} + +func policyTestAssignment(modify func(b *skipper.Assignment_builder)) *skipper.Assignment { + b := skipper.Assignment_builder{ + Namespace: new("ns"), + Deployment: new("deploy"), + Tenant: new("tenant"), + Scale: skipper.Scale_builder{ + MinInstances: proto.Uint32(1), + MaxInstances: proto.Uint32(10), + TargetCpuUsageMilli: proto.Uint32(500), + TargetMemoryUsageMib: proto.Uint32(256), + TargetInFlightRequests: proto.Uint32(100), + }.Build(), + } + if modify != nil { + modify(&b) + } + return b.Build() +} + +// TestHeartbeatTimeoutOverride confirms calculateDesiredInstances honors +// per-assignment heartbeat_timeout for the scale-to-zero decision. +func TestHeartbeatTimeoutOverride(t *testing.T) { + t.Parallel() + + cfg := testConfig() + cfg.HeartbeatTimeout = 90 * time.Second + + staleAt := time.Now().Add(-60 * time.Second) // beyond 30s, within 90s + + defaultFn := policyTestAssignment(nil) + overriddenFn := policyTestAssignment(func(b *skipper.Assignment_builder) { + b.HeartbeatTimeout = durationpb.New(30 * time.Second) + }) + + // Cluster default 90s: 60s-old heartbeat is fresh → no scale-to-zero. + dDefault := calculateDesiredInstances(t.Context(), defaultFn, cfg, withHeartbeat(defaultFn, staleAt, 0), nil) + assert.Assert(t, dDefault.GetReason() != skipper.ScaleReason_SCALE_REASON_HEARTBEAT_TIMEOUT, + "cluster-default timeout should treat 60s-old heartbeat as fresh; got reason=%v", dDefault.GetReason()) + + // Tenant override 30s: 60s-old heartbeat triggers scale-to-zero. + dOverride := calculateDesiredInstances(t.Context(), overriddenFn, cfg, withHeartbeat(overriddenFn, staleAt, 0), nil) + assert.Equal(t, dOverride.GetReason(), skipper.ScaleReason_SCALE_REASON_HEARTBEAT_TIMEOUT) + assert.Equal(t, dOverride.GetDesiredInstances(), uint32(0)) +} + +// TestScaleToleranceOverride confirms calculateDesiredInstancesForMetric reads +// the tenant override of scale_tolerance. +func TestScaleToleranceOverride(t *testing.T) { + t.Parallel() + + cfg := testConfig() + cfg.HPATolerance = 0.1 // 10% — wide enough to swallow modest discrepancies + cfg.HPAInitialReadinessDelay = 0 + + // Two instances at 250m, target 500m, currentInstances=2 → averageUsage 250m, + // usageRatio 0.5 → discrepancy 0.5 — outside any reasonable tolerance, so + // the algorithm wants to scale down. + readyAt := time.Now().Add(-time.Hour) + instances := []*skipper.Instance{ + skipper.Instance_builder{ReadyAt: timestamppb.New(readyAt), CpuUsageMilli: proto.Uint32(250)}.Build(), + skipper.Instance_builder{ReadyAt: timestamppb.New(readyAt), CpuUsageMilli: proto.Uint32(250)}.Build(), + } + + defaultFn := policyTestAssignment(nil) + wideTolerance := policyTestAssignment(func(b *skipper.Assignment_builder) { + // Tenant override pushes tolerance up to 60% — the 50% discrepancy now + // falls inside the no-scale window. + b.ScaleTolerance = new(0.6) + }) + + dDefault, _ := calculateDesiredInstancesForMetric(t.Context(), defaultFn, cfg, MetricCPU, instances) + assert.Assert(t, dDefault < 2, "cluster-default tolerance should scale down; got %d", dDefault) + + dOverride, _ := calculateDesiredInstancesForMetric(t.Context(), wideTolerance, cfg, MetricCPU, instances) + assert.Equal(t, dOverride, 2, "wide tenant tolerance should hold current instance count") +} + +// TestInitialReadinessDelayOverride confirms calculateDesiredInstancesForMetric +// reads the tenant override of scale_initial_readiness_delay. +func TestInitialReadinessDelayOverride(t *testing.T) { + t.Parallel() + + cfg := testConfig() + cfg.HPAInitialReadinessDelay = 1 * time.Hour // huge cluster default — pods always "too new" + cfg.HPATolerance = 0 + + // Pod ready 10 minutes ago. + readyAt := time.Now().Add(-10 * time.Minute) + instances := []*skipper.Instance{ + skipper.Instance_builder{ReadyAt: timestamppb.New(readyAt), CpuUsageMilli: proto.Uint32(1000)}.Build(), + } + + // Cluster default treats the pod as too-new → no metrics → no scale-up. + defaultFn := policyTestAssignment(nil) + dDefault, _ := calculateDesiredInstancesForMetric(t.Context(), defaultFn, cfg, MetricCPU, instances) + assert.Equal(t, dDefault, 1, "cluster default of 1h readiness delay excludes the pod") + + // Tenant override of 1m treats the 10m-ready pod as eligible — usage 1000m + // versus target 500m drives scale-up. + shortDelayFn := policyTestAssignment(func(b *skipper.Assignment_builder) { + b.ScaleInitialReadinessDelay = durationpb.New(1 * time.Minute) + }) + dOverride, _ := calculateDesiredInstancesForMetric(t.Context(), shortDelayFn, cfg, MetricCPU, instances) + assert.Assert(t, dOverride > 1, "tenant-override readiness delay should observe usage; got %d", dOverride) +} + +// TestDownscaleStabilizationOverride confirms recordRecommendation reads the +// tenant override of scale_downscale_stabilization. +func TestDownscaleStabilizationOverride(t *testing.T) { + t.Parallel() + + cfg := testConfig() + cfg.HPADownscaleStabilization = 5 * time.Minute + + supervisorFor := func(t *testing.T, fn *skipper.Assignment) *Supervisor { + t.Helper() + ctrl := &Controller{config: cfg} + s := &Supervisor{ctrl: ctrl} + s.fn.Store(fn) + return s + } + + // Pre-populate stabilization window with an old recommendation (3 min ago) + // and a fresh one (now). + mkSupervisor := func(t *testing.T, fn *skipper.Assignment) *Supervisor { + s := supervisorFor(t, fn) + s.stabilizationWindow = []Recommendation{ + {DesiredInstances: 5, Timestamp: time.Now().Add(-3 * time.Minute)}, + } + return s + } + + // Cluster default 5m: 3-min-old recommendation falls inside the window. + defaultFn := policyTestAssignment(nil) + sDefault := mkSupervisor(t, defaultFn) + maxDefault := sDefault.recordRecommendation(defaultFn, 1) + assert.Equal(t, maxDefault.DesiredInstances, uint32(5), + "cluster-default stabilization should retain 3-min-old recommendation") + + // Tenant override of 1 minute: 3-min-old recommendation is pruned. + shortFn := policyTestAssignment(func(b *skipper.Assignment_builder) { + b.ScaleDownscaleStabilization = durationpb.New(1 * time.Minute) + }) + sShort := mkSupervisor(t, shortFn) + maxShort := sShort.recordRecommendation(shortFn, 1) + assert.Equal(t, maxShort.DesiredInstances, uint32(1), + "tenant-override stabilization should drop 3-min-old recommendation") +} + +// TestAssignTimeoutOverride confirms cleanupStuckInstances reads the +// per-assignment assign_timeout when deciding whether an instance is stuck. +// We exercise the keep-path (override widens the threshold past the +// instance's age) to avoid touching the apiserver in this unit test; +// integration coverage of the delete-path lives in TestCleanupStuckInstance. +func TestAssignTimeoutOverride(t *testing.T) { + t.Parallel() + + cfg := testConfig() + cfg.AssignTimeout = 30 * time.Second + + ctrl := &Controller{config: cfg} + + // Tenant override 5 minutes → stuck threshold = 10 minutes → 90s-old + // instance is fresh; cleanupStuckInstances should keep it without + // calling deletePod. + longFn := policyTestAssignment(func(b *skipper.Assignment_builder) { + b.AssignTimeout = durationpb.New(5 * time.Minute) + }) + instance := skipper.Instance_builder{ + Name: new("fresh-pod"), + AssignedAt: timestamppb.New(time.Now().Add(-90 * time.Second)), + }.Build() + instance.SetAssignment(longFn) + s := &Supervisor{ctrl: ctrl} + s.fn.Store(longFn) + left := s.cleanupStuckInstances(t.Context(), longFn, []*skipper.Instance{instance}) + assert.Equal(t, len(left), 1, "tenant override should keep 90s-old instance under 5m timeout") +} + +// TestScaleTargetCPUOverride confirms calculateDesiredInstances reads the +// per-assignment scale_target_cpu_millicores (flat-preferred-over-Scale). +func TestScaleTargetCPUOverride(t *testing.T) { + t.Parallel() + + cfg := testConfig() + cfg.HPATolerance = 0 + cfg.HPAInitialReadinessDelay = 0 + cfg.HeartbeatTimeout = 1 * time.Hour + + readyAt := time.Now().Add(-time.Hour) + instances := []*skipper.Instance{ + skipper.Instance_builder{ReadyAt: timestamppb.New(readyAt), CpuUsageMilli: proto.Uint32(500)}.Build(), + } + hb := &skipper.Heartbeat{} + hb.SetTimestamp(timestamppb.Now()) + + // Nested-only: target 500m, usage 500m → ratio 1 → no scaling. + nestedFn := policyTestAssignment(nil) + hb.SetAssignment(nestedFn) + dNested := calculateDesiredInstances(t.Context(), nestedFn, cfg, hb, instances) + assert.Equal(t, dNested.GetDesiredInstances(), uint32(1)) + + // Flat override 100m: usage 500m → ratio 5 → scale up. + flatFn := policyTestAssignment(func(b *skipper.Assignment_builder) { + b.ScaleTargetCpuMillicores = proto.Uint32(100) + }) + hb.SetAssignment(flatFn) + dFlat := calculateDesiredInstances(t.Context(), flatFn, cfg, hb, instances) + assert.Assert(t, dFlat.GetDesiredInstances() > 1, "flat-preferred target should drive scale-up; got %d", dFlat.GetDesiredInstances()) +} + +// TestPlaceholderKnobsNoEffectOnSupervisor confirms that setting any +// placeholder knob (transport_*, zone_*, retry_backpressure, retry_status_codes, +// heartbeat_interval, assign_path) leaves the supervisor's decisions identical +// to an assignment that did not set the knob. +func TestPlaceholderKnobsNoEffectOnSupervisor(t *testing.T) { + t.Parallel() + + cfg := testConfig() + cfg.HPATolerance = 0 + cfg.HPAInitialReadinessDelay = 0 + cfg.HeartbeatTimeout = 1 * time.Hour + + hb := &skipper.Heartbeat{} + hb.SetTimestamp(timestamppb.Now()) + hb.SetInFlightRequests(5) + instances := []*skipper.Instance{ + skipper.Instance_builder{ReadyAt: timestamppb.New(time.Now().Add(-time.Hour)), CpuUsageMilli: proto.Uint32(500)}.Build(), + } + + baseFn := policyTestAssignment(nil) + hb.SetAssignment(baseFn) + baseDecision := calculateDesiredInstances(t.Context(), baseFn, cfg, hb, instances) + + withPlaceholders := policyTestAssignment(func(b *skipper.Assignment_builder) { + b.ZoneSpread = skipper.ZoneSpread_ZONE_SPREAD_REQUIRED.Enum() + b.ZoneMin = proto.Uint32(3) + b.ZoneAffinity = skipper.ZoneAffinity_ZONE_AFFINITY_REQUIRED.Enum() + b.AssignPath = new("/__skipper/v2/assign") + b.HeartbeatInterval = durationpb.New(7 * time.Second) + b.RetryBackpressure = skipper.Backpressure_BACKPRESSURE_RETRY_AND_EJECT.Enum() + b.RetryStatusCodes = []uint32{503, 504, 529} + b.TransportDialTimeout = durationpb.New(100 * time.Millisecond) + b.TransportKeepalive = durationpb.New(30 * time.Second) + b.TransportIdleConnTimeout = durationpb.New(90 * time.Second) + b.TransportTlsHandshakeTimeout = durationpb.New(10 * time.Second) + b.TransportMaxIdleConns = proto.Uint32(200) + b.TransportForceHttp2 = new(true) + b.TransportDisableCompression = new(true) + b.TransportFlushInterval = durationpb.New(100 * time.Millisecond) + }) + hb.SetAssignment(withPlaceholders) + placeholderDecision := calculateDesiredInstances(t.Context(), withPlaceholders, cfg, hb, instances) + + assert.Equal(t, placeholderDecision.GetDesiredInstances(), baseDecision.GetDesiredInstances(), + "placeholder knobs must not affect supervisor scaling decisions") + assert.Equal(t, placeholderDecision.GetReason(), baseDecision.GetReason()) +} diff --git a/internal/controller/supervisor.go b/internal/controller/supervisor.go index 27f4bcb4..c50205d1 100644 --- a/internal/controller/supervisor.go +++ b/internal/controller/supervisor.go @@ -172,9 +172,12 @@ func (s *Supervisor) heartbeat(routerIP string, heartbeat *skipper.Heartbeat) { return existing, xsync.CancelOp }) - // garbage collect expired router heartbeats + // garbage collect expired router heartbeats. Resolve heartbeat_timeout + // from the current assignment snapshot so a per-tenant override applies + // to this supervisor's GC. + timeout := s.fn.Load().HeartbeatTimeout(s.ctrl.config.HeartbeatTimeout) for routerIP, heartbeat := range s.routerHeartbeats.AllRelaxed() { - if time.Since(heartbeat.GetTimestamp().AsTime()) > s.ctrl.config.HeartbeatTimeout { + if time.Since(heartbeat.GetTimestamp().AsTime()) > timeout { s.routerHeartbeats.Delete(routerIP) } } @@ -215,9 +218,11 @@ func (s *Supervisor) combinedHeartbeat(fn *skipper.Assignment, instances []*skip // recordRecommendation adds a scaling recommendation to the stabilization window, // prunes expired entries, and returns the maximum recommendation within the window. -func (s *Supervisor) recordRecommendation(desiredInstances uint32) Recommendation { +// The fn snapshot resolves the per-assignment downscale-stabilization window with +// fallback to the cluster default. +func (s *Supervisor) recordRecommendation(fn *skipper.Assignment, desiredInstances uint32) Recommendation { now := time.Now() - cutoff := now.Add(-s.ctrl.config.HPADownscaleStabilization) + cutoff := now.Add(-fn.ScaleDownscaleStabilization(s.ctrl.config.HPADownscaleStabilization)) s.stabilizationWindow = append(s.stabilizationWindow, Recommendation{ DesiredInstances: desiredInstances, Timestamp: now, @@ -283,7 +288,7 @@ func (s *Supervisor) converge(ctx context.Context) error { heartbeat := s.combinedHeartbeat(fn, instances) ctx = telemetry.With(ctx, skipper.LegacyFunctionKey.Attr(fn), skipper.AssignmentKey.Attr(fn), skipper.HeartbeatKey.Attr(heartbeat)) - scalingDecision := calculateDesiredInstances(ctx, s.ctrl.config, heartbeat, instances) + scalingDecision := calculateDesiredInstances(ctx, fn, s.ctrl.config, heartbeat, instances) // For oneshot functions, the converge loop is a safety net only. // All assignment happens synchronously in GetInstance via assignPod. @@ -299,7 +304,7 @@ func (s *Supervisor) converge(ctx context.Context) error { // oneshot requests (older than HeartbeatTimeout), this would incorrectly // trigger heartbeat_timeout before the router's heartbeats have arrived. // Skip deletion until we've been running long enough to receive heartbeats. - if time.Since(s.ctrl.StartedAt()) < s.ctrl.config.HeartbeatTimeout { + if time.Since(s.ctrl.StartedAt()) < fn.HeartbeatTimeout(s.ctrl.config.HeartbeatTimeout) { return nil } @@ -322,7 +327,7 @@ func (s *Supervisor) converge(ctx context.Context) error { return nil } - maxRecommendation := s.recordRecommendation(scalingDecision.GetDesiredInstances()) + maxRecommendation := s.recordRecommendation(fn, scalingDecision.GetDesiredInstances()) currentInstances := uint32(len(instances)) isScalingDown := scalingDecision.GetDesiredInstances() < currentInstances || scalingDecision.GetDesiredInstances() == 0 @@ -334,7 +339,10 @@ func (s *Supervisor) converge(ctx context.Context) error { // 2. Receive heartbeats from routers (HeartbeatTimeout) - without this, functions with // instances assigned longer than HeartbeatTimeout ago would immediately trigger // heartbeat_timeout scale-to-zero when a new controller starts with empty heartbeat state - protectionPeriod := max(s.ctrl.config.HPADownscaleStabilization, s.ctrl.config.HeartbeatTimeout) + protectionPeriod := max( + fn.ScaleDownscaleStabilization(s.ctrl.config.HPADownscaleStabilization), + fn.HeartbeatTimeout(s.ctrl.config.HeartbeatTimeout), + ) if time.Since(s.ctrl.StartedAt()) < protectionPeriod { log.Debug(ctx, "skipping scale down because controller hasn't been running long enough", slog.Time("started_at", s.ctrl.StartedAt())) return nil @@ -367,7 +375,7 @@ func (s *Supervisor) converge(ctx context.Context) error { } // 3. Cleanup stuck instances (cheap, run before scaling execution) - instances = s.cleanupStuckInstances(ctx, instances) + instances = s.cleanupStuckInstances(ctx, fn, instances) // 4. Execute scaling ready, unready, err := s.scaleWithoutLock(ctx, fn, instances, scalingDecision) @@ -435,7 +443,9 @@ func (s *Supervisor) scaleWithoutLock(ctx context.Context, fn *skipper.Assignmen key.UnreadyInstances.Slog(len(unreadyInstances)), ) - if fn.GetScale().GetMaxInstances() > 1 && decision.GetUnclampedDesiredInstances() > decision.GetDesiredInstances() { + maxInstances := fn.ScaleMaxInstances() + + if maxInstances > 1 && decision.GetUnclampedDesiredInstances() > decision.GetDesiredInstances() { // this function is allowed to scale beyond a single instance // and it wanted to scale up higher than its max instances, so // let's log that for observability @@ -455,7 +465,7 @@ func (s *Supervisor) scaleWithoutLock(ctx context.Context, fn *skipper.Assignmen if desired > ready { // we need to scale up - if total >= fn.GetScale().GetMaxInstances()+1 { + if total >= maxInstances+1 { // we have too many instances in total, so we can't scale up log.Info(ctx, "skipping scale up because function has too many instances") return readyInstances, unreadyInstances, nil @@ -524,12 +534,15 @@ func (s *Supervisor) scaleWithoutLock(ctx context.Context, fn *skipper.Assignmen } // cleanupStuckInstances terminates instances that are stuck in the -// assigned state (not ready) for longer than AssignTimeout*2. +// assigned state (not ready) for longer than AssignTimeout*2. The fn +// snapshot resolves the per-assignment assign-timeout window with +// fallback to the cluster default. // This is a cheap operation (just deletes) and should be called before // scaling execution to remove broken pods from consideration. -func (s *Supervisor) cleanupStuckInstances(ctx context.Context, instances []*skipper.Instance) []*skipper.Instance { +func (s *Supervisor) cleanupStuckInstances(ctx context.Context, fn *skipper.Assignment, instances []*skipper.Instance) []*skipper.Instance { + stuckThreshold := fn.AssignTimeout(s.ctrl.config.AssignTimeout) * 2 return slices.DeleteFunc(instances, func(instance *skipper.Instance) bool { - if !instance.HasReadyAt() && time.Since(instance.GetAssignedAt().AsTime()) > s.ctrl.config.AssignTimeout*2 { + if !instance.HasReadyAt() && time.Since(instance.GetAssignedAt().AsTime()) > stuckThreshold { ctx := log.With(ctx, skipper.InstanceKey.Slog(instance)) log.Warn(ctx, "terminating instance stuck in assigned state") err := s.ctrl.deletePod(ctx, instance.GetAssignment().GetNamespace(), instance.GetName(), metav1.DeleteOptions{}) @@ -597,7 +610,7 @@ func (s *Supervisor) replaceStaleInstances(ctx context.Context, fn *skipper.Assi // This can happen if a previous iteration assigned a replacement but failed // to delete the stale pod. In this case, keep the stale instance and let // scale() handle cleanup. - if currentTotalInstances >= int(fn.GetScale().GetMaxInstances())+1 { + if currentTotalInstances >= int(fn.ScaleMaxInstances())+1 { log.Info(ctx, "skipping stale instance replacement, already at maxInstances+1") continue } @@ -672,13 +685,14 @@ func (s *Supervisor) getReadyInstance(ctx context.Context, excludeNames []string } } - if len(instances) > int(fn.GetScale().GetMaxInstances()) { + maxInstances := fn.ScaleMaxInstances() + if len(instances) > int(maxInstances) { // sort instances by assigned at in descending order (newest first) slices.SortFunc(instances, func(a, b *skipper.Instance) int { return b.GetAssignedAt().AsTime().Compare(a.GetAssignedAt().AsTime()) }) // keep the newest instances up to the max instances allowed for the function - instances = instances[:fn.GetScale().GetMaxInstances()] + instances = instances[:maxInstances] } if len(excludeNames) > 0 { @@ -719,12 +733,16 @@ func scaleToZeroEvent(reason skipper.ScaleReason) (skipper.EventType, string) { return skipper.EventType_EVENT_TYPE_SCALE_DOWN, "scaling to 0" } -// calculateDesiredInstancesForMetric computes desired instances based on a single metric -func calculateDesiredInstancesForMetric(_ context.Context, cfg *Config, metric Metric, instances []*skipper.Instance) (int, float64) { +// calculateDesiredInstancesForMetric computes desired instances based on a single metric. +// fn is the converge-tick snapshot; scale targets and HPA tolerance / readiness +// delay are resolved through it so a tenant override applies uniformly. +func calculateDesiredInstancesForMetric(_ context.Context, fn *skipper.Assignment, cfg *Config, metric Metric, instances []*skipper.Instance) (int, float64) { currentInstances := len(instances) var instancesWithMetrics []*skipper.Instance var instancesWithoutMetrics []*skipper.Instance + readinessDelay := fn.ScaleInitialReadinessDelay(cfg.HPAInitialReadinessDelay) + for _, instance := range instances { var usage uint32 switch metric { @@ -736,7 +754,7 @@ func calculateDesiredInstancesForMetric(_ context.Context, cfg *Config, metric M return currentInstances, 0 } - if metric == MetricCPU && (!instance.HasReadyAt() || time.Since(instance.GetReadyAt().AsTime()) <= cfg.HPAInitialReadinessDelay) { + if metric == MetricCPU && (!instance.HasReadyAt() || time.Since(instance.GetReadyAt().AsTime()) <= readinessDelay) { // ignore CPU metrics for pods that have been ready for less than the initial readiness delay instancesWithoutMetrics = append(instancesWithoutMetrics, instance) continue @@ -754,15 +772,18 @@ func calculateDesiredInstancesForMetric(_ context.Context, cfg *Config, metric M } var targetUsage uint32 + switch metric { + case MetricCPU: + targetUsage = fn.ScaleTargetCPUMillicores() + case MetricMemory: + targetUsage = fn.ScaleTargetMemoryMebibytes() + } var totalUsage uint32 for _, instance := range instancesWithMetrics { - // accumulate total usage and keep track of target usage (they should all be identical) switch metric { case MetricCPU: - targetUsage = instance.GetAssignment().GetScale().GetTargetCpuUsageMilli() totalUsage += instance.GetCpuUsageMilli() case MetricMemory: - targetUsage = instance.GetAssignment().GetScale().GetTargetMemoryUsageMib() totalUsage += instance.GetMemoryUsageMib() } } @@ -772,12 +793,14 @@ func calculateDesiredInstancesForMetric(_ context.Context, cfg *Config, metric M return currentInstances, 0 } + tolerance := fn.ScaleTolerance(cfg.HPATolerance) + averageUsage := float64(totalUsage) / float64(len(instancesWithMetrics)) usageRatio := averageUsage / float64(targetUsage) usageDiscrepancy := math.Abs(1.0 - usageRatio) desiredInstances := int(math.Ceil(float64(currentInstances) * usageRatio)) - if usageDiscrepancy <= cfg.HPATolerance+1e-10 { // add a small epsilon to avoid floating point errors + if usageDiscrepancy <= tolerance+1e-10 { // add a small epsilon to avoid floating point errors // the average usage is within tolerance of the target utilization, so we should not scale return currentInstances, 0 } @@ -797,7 +820,7 @@ func calculateDesiredInstancesForMetric(_ context.Context, cfg *Config, metric M if (adjustedUsageRatio > 1.0 && usageRatio < 1.0) || (adjustedUsageRatio < 1.0 && usageRatio > 1.0) || - math.Abs(1.0-adjustedUsageRatio) <= cfg.HPATolerance+1e-10 { + math.Abs(1.0-adjustedUsageRatio) <= tolerance+1e-10 { // the adjusted usage ratio is the opposite of the original // usage ratio, or the adjusted usage ratio is within // tolerance of the target utilization. either way, we @@ -811,9 +834,12 @@ func calculateDesiredInstancesForMetric(_ context.Context, cfg *Config, metric M return desiredInstances, averageUsage } -// calculateDesiredInstances computes desired instances based on multiple metrics -func calculateDesiredInstances(ctx context.Context, cfg *Config, heartbeat *skipper.Heartbeat, instances []*skipper.Instance) *skipper.ScaleDecision { - if !heartbeat.HasTimestamp() || time.Since(heartbeat.GetTimestamp().AsTime()) >= cfg.HeartbeatTimeout { +// calculateDesiredInstances computes desired instances based on multiple metrics. +// All per-assignment knobs (heartbeat timeout, scale targets, min/max bounds) +// are read from the supplied fn snapshot so a converge tick observes a single +// view of the tenant's policy. +func calculateDesiredInstances(ctx context.Context, fn *skipper.Assignment, cfg *Config, heartbeat *skipper.Heartbeat, instances []*skipper.Instance) *skipper.ScaleDecision { + if !heartbeat.HasTimestamp() || time.Since(heartbeat.GetTimestamp().AsTime()) >= fn.HeartbeatTimeout(cfg.HeartbeatTimeout) { decision := &skipper.ScaleDecision{} decision.SetDesiredInstances(0) decision.SetUnclampedDesiredInstances(0) @@ -821,11 +847,16 @@ func calculateDesiredInstances(ctx context.Context, cfg *Config, heartbeat *skip return decision } + minInst := fn.ScaleMinInstances() + maxInst := fn.ScaleMaxInstances() + targetCPU := fn.ScaleTargetCPUMillicores() + targetMem := fn.ScaleTargetMemoryMebibytes() + targetIFR := fn.ScaleTargetInFlightRequests() + // Oneshot functions scale 1:1 with in-flight requests. - if heartbeat.GetAssignment().GetOneshot() { + if fn.GetOneshot() { desiredInstances := int(heartbeat.GetInFlightRequests()) - scale := heartbeat.GetAssignment().GetScale() - clamped := min(max(uint32(desiredInstances), scale.GetMinInstances()), scale.GetMaxInstances()) + clamped := min(max(uint32(desiredInstances), minInst), maxInst) metric := &skipper.ScaleMetric{} metric.SetName("in_flight_requests") @@ -845,10 +876,8 @@ func calculateDesiredInstances(ctx context.Context, cfg *Config, heartbeat *skip var scaleReason skipper.ScaleReason var scaleMetrics []*skipper.ScaleMetric - scale := heartbeat.GetAssignment().GetScale() - - if scale.GetTargetInFlightRequests() > 0 { - desiredInstances := int(math.Ceil(float64(heartbeat.GetInFlightRequests()) / float64(scale.GetTargetInFlightRequests()))) + if targetIFR > 0 { + desiredInstances := int(math.Ceil(float64(heartbeat.GetInFlightRequests()) / float64(targetIFR))) averageUsage := float64(heartbeat.GetInFlightRequests()) / float64(len(instances)) metric := &skipper.ScaleMetric{} metric.SetName("in_flight_requests") @@ -860,8 +889,8 @@ func calculateDesiredInstances(ctx context.Context, cfg *Config, heartbeat *skip } } - if scale.GetTargetCpuUsageMilli() > 0 { - desiredInstances, averageUsage := calculateDesiredInstancesForMetric(ctx, cfg, MetricCPU, instances) + if targetCPU > 0 { + desiredInstances, averageUsage := calculateDesiredInstancesForMetric(ctx, fn, cfg, MetricCPU, instances) metric := &skipper.ScaleMetric{} metric.SetName("cpu") metric.SetValue(averageUsage) @@ -872,8 +901,8 @@ func calculateDesiredInstances(ctx context.Context, cfg *Config, heartbeat *skip } } - if scale.GetTargetMemoryUsageMib() > 0 { - desiredInstances, averageUsage := calculateDesiredInstancesForMetric(ctx, cfg, MetricMemory, instances) + if targetMem > 0 { + desiredInstances, averageUsage := calculateDesiredInstancesForMetric(ctx, fn, cfg, MetricMemory, instances) metric := &skipper.ScaleMetric{} metric.SetName("memory") metric.SetValue(averageUsage) @@ -884,10 +913,7 @@ func calculateDesiredInstances(ctx context.Context, cfg *Config, heartbeat *skip } } - // Apply min/max clamping - minInstances := scale.GetMinInstances() - maxInstances := scale.GetMaxInstances() - clampedValue := min(max(uint32(maxDesiredInstances), minInstances), maxInstances) + clampedValue := min(max(uint32(maxDesiredInstances), minInst), maxInst) decision := &skipper.ScaleDecision{} decision.SetDesiredInstances(clampedValue) diff --git a/internal/controller/supervisor_test.go b/internal/controller/supervisor_test.go index ae4ae39f..05dd3e99 100644 --- a/internal/controller/supervisor_test.go +++ b/internal/controller/supervisor_test.go @@ -1178,22 +1178,23 @@ func TestCalculateDesiredInstancesForMetric(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - // Configure target usage for each instance - for _, pm := range tc.podMetrics { - if pm.GetAssignment() == nil { - pm.SetAssignment(skipper.Assignment_builder{ - Scale: skipper.Scale_builder{}.Build(), - }.Build()) - } - switch tc.metricName { - case MetricCPU: - pm.GetAssignment().GetScale().SetTargetCpuUsageMilli(tc.targetUsage) - case MetricMemory: - pm.GetAssignment().GetScale().SetTargetMemoryUsageMib(tc.targetUsage) - } + // Configure target usage on a single fn snapshot — the new signature + // reads scale targets from the supplied Assignment, not from each + // instance's historical assignment. + fn := skipper.Assignment_builder{ + Namespace: new("ns"), + Deployment: new("deploy"), + Tenant: new("tenant"), + Scale: skipper.Scale_builder{MaxInstances: proto.Uint32(10)}.Build(), + }.Build() + switch tc.metricName { + case MetricCPU: + fn.GetScale().SetTargetCpuUsageMilli(tc.targetUsage) + case MetricMemory: + fn.GetScale().SetTargetMemoryUsageMib(tc.targetUsage) } - instances, _ := calculateDesiredInstancesForMetric(t.Context(), cfg, tc.metricName, tc.podMetrics) + instances, _ := calculateDesiredInstancesForMetric(t.Context(), fn, cfg, tc.metricName, tc.podMetrics) assert.Assert(t, instances == tc.expectedInstances) }) } @@ -1869,7 +1870,7 @@ func TestCalculateDesiredInstances(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - decision := calculateDesiredInstances(t.Context(), cfg, tc.heartbeat, tc.instances) + decision := calculateDesiredInstances(t.Context(), tc.heartbeat.GetAssignment(), cfg, tc.heartbeat, tc.instances) assert.Equal(t, tc.expectedDesiredInstances, decision.GetDesiredInstances()) assert.Equal(t, tc.expectedUnclampedDesired, decision.GetUnclampedDesiredInstances()) @@ -2214,7 +2215,7 @@ func TestCleanupStuckInstances(t *testing.T) { assert.NilError(t, err) supervisor := state.ctrl.supervisor(state.fn) - instances = supervisor.cleanupStuckInstances(ctx, instances) + instances = supervisor.cleanupStuckInstances(ctx, state.fn, instances) tc.check(t, state, instances) }) @@ -3257,7 +3258,7 @@ func TestConvergeDoesNotReplaceStaleInstancesWhenScalingDown(t *testing.T) { // Verify the scaling decision would request 1 instance heartbeat := supervisor.combinedHeartbeat(fn, instances) - scalingDecision := calculateDesiredInstances(ctx, cfg, heartbeat, instances) + scalingDecision := calculateDesiredInstances(ctx, fn, cfg, heartbeat, instances) assert.Assert(t, scalingDecision.GetDesiredInstances() == 1, "expected scaling decision of 1 instance, got %d", scalingDecision.GetDesiredInstances()) @@ -3816,7 +3817,7 @@ func TestCalculateDesiredInstancesOneshot(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - decision := calculateDesiredInstances(t.Context(), cfg, tc.heartbeat, tc.instances) + decision := calculateDesiredInstances(t.Context(), tc.heartbeat.GetAssignment(), cfg, tc.heartbeat, tc.instances) assert.Equal(t, tc.expectedDesiredInstances, decision.GetDesiredInstances()) assert.Equal(t, tc.expectedUnclampedDesired, decision.GetUnclampedDesiredInstances()) @@ -4026,6 +4027,13 @@ func TestOneshotProtectionPeriodPreventsOrphanDeletion(t *testing.T) { var sinkRecommendation Recommendation +var benchAssignment = skipper.Assignment_builder{ + Namespace: new("ns"), + Deployment: new("deploy"), + Tenant: new("tenant"), + Scale: skipper.Scale_builder{MinInstances: proto.Uint32(1), MaxInstances: proto.Uint32(10)}.Build(), +}.Build() + func BenchmarkRecordRecommendation(b *testing.B) { cfg := testConfig() @@ -4052,7 +4060,7 @@ func BenchmarkRecordRecommendation(b *testing.B) { s := &Supervisor{ctrl: &Controller{config: cfg}} for b.Loop() { s.stabilizationWindow = append(s.stabilizationWindow[:0], template...) - sinkRecommendation = s.recordRecommendation(5) + sinkRecommendation = s.recordRecommendation(benchAssignment, 5) } }) @@ -4061,7 +4069,7 @@ func BenchmarkRecordRecommendation(b *testing.B) { s := &Supervisor{ctrl: &Controller{config: cfg}} for b.Loop() { s.stabilizationWindow = s.stabilizationWindow[:0] - sinkRecommendation = s.recordRecommendation(5) + sinkRecommendation = s.recordRecommendation(benchAssignment, 5) } }) } @@ -4159,7 +4167,7 @@ func TestSupervisorEvents(t *testing.T) { instances, err := state.ctrl.getInstances(ctx, state.fn) assert.NilError(t, err) supervisor := state.ctrl.supervisor(state.fn) - supervisor.cleanupStuckInstances(ctx, instances) + supervisor.cleanupStuckInstances(ctx, state.fn, instances) }, check: func(t *testing.T, state *testState) { events := state.ctrl.events.snapshot() diff --git a/internal/dev/docssite/messagetable.go b/internal/dev/docssite/messagetable.go index 065bfa1e..c5012f7d 100644 --- a/internal/dev/docssite/messagetable.go +++ b/internal/dev/docssite/messagetable.go @@ -56,9 +56,44 @@ var messageTableDescriptions = map[string]string{ "Assignment.deployment": "Source deployment label that supplies the pod pool.", "Assignment.tenant": "Tenant identifier; combined with namespace, deployment, and oneshot to compute the assignment hash.", "Assignment.metadata": "Opaque string passed verbatim to the assigned pod; not part of the assignment hash.", - "Assignment.scale": "Per-assignment scaling targets (min, max, CPU, memory, in-flight requests).", + "Assignment.scale": "Per-assignment scaling targets (min, max, CPU, memory, in-flight requests). Flat-form `scale_*` fields take precedence when both are set.", "Assignment.oneshot": "True if each request gets a fresh pod; assigned pods are released after the request completes.", + "Assignment.scale_min_instances": "Minimum ready-instance floor (flat alias for `scale.min_instances`).", + "Assignment.scale_max_instances": "Hard ceiling on ready instances (flat alias for `scale.max_instances`).", + "Assignment.scale_target_cpu_millicores": "Per-instance CPU target in millicores (flat alias for `scale.target_cpu_usage_milli`).", + "Assignment.scale_target_memory_mebibytes": "Per-instance memory target in mebibytes (flat alias for `scale.target_memory_usage_mib`).", + "Assignment.scale_target_in_flight_requests": "Per-instance in-flight-request target (flat alias for `scale.target_in_flight_requests`).", + "Assignment.scale_tolerance": "HPA usage-ratio tolerance; usage within this band of the target produces no scale change.", + "Assignment.scale_downscale_stabilization": "Downscale stabilization window; the supervisor records recommendations over this period and scales down to the maximum within it.", + "Assignment.scale_initial_readiness_delay": "Per-pod initial-readiness delay during which CPU metrics are ignored.", + + "Assignment.zone_spread": "**Planned (followup: zone-aware-placement).** Spread-across-zones policy. Setting this field has no effect in the current release.", + "Assignment.zone_min": "**Planned (followup: zone-aware-placement).** Minimum number of zones to spread across. Setting this field has no effect in the current release.", + "Assignment.zone_affinity": "**Planned (followup: zone-aware-affinity).** Same-zone routing preference. Setting this field has no effect in the current release.", + + "Assignment.assign_path": "**Planned (followup: per-assignment-assign-path).** Override the controller's `/__skipper/assign` POST path. Setting this field has no effect in the current release.", + "Assignment.assign_timeout": "Per-assignment override for the controller's assign POST timeout.", + "Assignment.assign_token_ttl": "Per-assignment override for the PASETO token's time-to-live.", + + "Assignment.heartbeat_interval": "**Planned (followup: per-assignment-heartbeat-interval).** Override the router's heartbeat send interval. Setting this field has no effect in the current release.", + "Assignment.heartbeat_timeout": "Per-assignment override for the controller's heartbeat timeout (scale-to-zero and pod-cleanup decisions).", + + "Assignment.retry_max_attempts": "Per-assignment override for the router's max round-trip retry attempts.", + "Assignment.retry_min_backoff": "Per-assignment override for the router's minimum retry backoff.", + "Assignment.retry_max_backoff": "Per-assignment override for the router's maximum retry backoff.", + "Assignment.retry_backpressure": "**Planned (followup: backpressure).** Backpressure response policy. Setting this field has no effect in the current release.", + "Assignment.retry_status_codes": "**Planned (followup: backpressure).** HTTP status codes that should trigger a retry. Setting this field has no effect in the current release.", + + "Assignment.transport_dial_timeout": "**Planned (followup: per-assignment-transport).** Transport dial timeout. Setting this field has no effect in the current release.", + "Assignment.transport_keepalive": "**Planned (followup: per-assignment-transport).** Transport TCP keepalive interval. Setting this field has no effect in the current release.", + "Assignment.transport_idle_conn_timeout": "**Planned (followup: per-assignment-transport).** Transport idle-connection timeout. Setting this field has no effect in the current release.", + "Assignment.transport_tls_handshake_timeout": "**Planned (followup: per-assignment-transport).** Transport TLS handshake timeout. Setting this field has no effect in the current release.", + "Assignment.transport_max_idle_conns": "**Planned (followup: per-assignment-transport).** Transport max idle connections. Setting this field has no effect in the current release.", + "Assignment.transport_force_http2": "**Planned (followup: per-assignment-transport).** Force HTTP/2 on the transport. Setting this field has no effect in the current release.", + "Assignment.transport_disable_compression": "**Planned (followup: per-assignment-transport).** Disable transport-level compression. Setting this field has no effect in the current release.", + "Assignment.transport_flush_interval": "**Planned (followup: per-assignment-transport).** Reverse-proxy response flush interval. Setting this field has no effect in the current release.", + "Scale.min_instances": "Minimum ready-instance floor (0 enables scale-to-zero).", "Scale.max_instances": "Hard ceiling on ready instances.", "Scale.target_cpu_usage_milli": "Per-instance CPU target in millicores; the controller scales toward this average.", diff --git a/internal/dev/docssite/messagetable_test.go b/internal/dev/docssite/messagetable_test.go index 5f218a19..c407a8a6 100644 --- a/internal/dev/docssite/messagetable_test.go +++ b/internal/dev/docssite/messagetable_test.go @@ -2,6 +2,7 @@ package docssite import ( "errors" + "maps" "os" "path/filepath" "slices" @@ -180,15 +181,13 @@ func TestRenderMessageTable_MissingDescriptionFails(t *testing.T) { func TestRenderMessageTable_ExtraDescriptionFails(t *testing.T) { t.Parallel() - descriptions := map[string]string{ - "Assignment.namespace": "x", - "Assignment.deployment": "x", - "Assignment.tenant": "x", - "Assignment.metadata": "x", - "Assignment.scale": "x", - "Assignment.oneshot": "x", - "Assignment.bogus": "extraneous", - } + // Start from the live description map so adding fields to Assignment + // doesn't trip the missing-description check before the "extra key" + // check this case actually exercises. + descriptions := make(map[string]string, len(messageTableDescriptions)+1) + maps.Copy(descriptions, messageTableDescriptions) + descriptions["Assignment.bogus"] = "extraneous" + _, err := renderMessageRows("Assignment", descriptions, messageTableRegistry) assert.Assert(t, err != nil) assert.Assert(t, errors.Is(err, errMessageDescriptionDrift)) diff --git a/internal/key/keys.go b/internal/key/keys.go index 5f8571a6..747ca2e8 100644 --- a/internal/key/keys.go +++ b/internal/key/keys.go @@ -3,49 +3,52 @@ package key // Pre-defined keys for common attributes used throughout skipper. // Each key provides consistent naming across logs, traces, headers, and labels. var ( - Addr = stringKey("address") - AssignedAt = timeKey("assigned_at") - Attempt = intKey("attempt") - CPUUsageMilli = uint32Key("cpu_usage_milli") - Controller = newNames("controller") - Count = intKey("count") - Deployment = stringKey("deployment") - DesiredInstances = uint32Key("desired_instances") - Duration = durationKey("duration") - Error = errorKey("error") - ExcludeInstanceNames = stringSliceKey("exclude_instance_names") - ForwardedFor = newNames("forwarded_for") - GetInstanceDurationMs = durationKey("get_instance_duration_ms") - InFlightRequests = uint32Key("in_flight_requests") - K8sReplicaSet = replicaSetKey("k8s.replicaset") - Labels = mapStringStringKey("labels") - MaxInstances = uint32Key("max_instances") - MemoryUsageMiB = uint32Key("memory_usage_mib") - Metadata = stringKey("metadata") - MinInstances = uint32Key("min_instances") - Name = stringKey("name") - Namespace = stringKey("namespace") - Oneshot = boolKey("oneshot") - Pod = podKey("k8s.pod") - PodIP = stringKey("pod_ip") - Port = newNames("port") - ReadyAt = timeKey("ready_at") - ReadyInstances = intKey("ready_instances") - Reason = stringKey("reason") - ReplicaSet = stringKey("replica_set") - Request = requestKey("http.request") - Response = responseKey("http.response") - ResponsibleIP = stringKey("responsible_ip") - RouterIP = stringKey("router_ip") - TargetCPUUsageMilli = uint32Key("target_cpu_usage_milli") - TargetInFlightRequests = uint32Key("target_in_flight_requests") - TargetMemoryUsageMiB = uint32Key("target_memory_usage_mib") - Tenant = stringKey("tenant") - Timestamp = timeKey("timestamp") - Token = newNames("token") - URL = urlKey("url") - UnclampedDesiredInstances = uint32Key("unclamped_desired_instances") - UnreadyInstances = intKey("unready_instances") - Zone = stringKey("zone") - ZoneMatch = stringKey("zone_match") + Addr = stringKey("address") + AssignedAt = timeKey("assigned_at") + Attempt = intKey("attempt") + CPUUsageMilli = uint32Key("cpu_usage_milli") + Controller = newNames("controller") + Count = intKey("count") + Deployment = stringKey("deployment") + DesiredInstances = uint32Key("desired_instances") + Duration = durationKey("duration") + Error = errorKey("error") + ExcludeInstanceNames = stringSliceKey("exclude_instance_names") + ForwardedFor = newNames("forwarded_for") + GetInstanceDurationMs = durationKey("get_instance_duration_ms") + InFlightRequests = uint32Key("in_flight_requests") + K8sReplicaSet = replicaSetKey("k8s.replicaset") + Labels = mapStringStringKey("labels") + MaxInstances = uint32Key("max_instances") + MemoryUsageMiB = uint32Key("memory_usage_mib") + Metadata = stringKey("metadata") + MinInstances = uint32Key("min_instances") + Name = stringKey("name") + Namespace = stringKey("namespace") + Oneshot = boolKey("oneshot") + Pod = podKey("k8s.pod") + PodIP = stringKey("pod_ip") + Port = newNames("port") + ReadyAt = timeKey("ready_at") + ReadyInstances = intKey("ready_instances") + Reason = stringKey("reason") + ReplicaSet = stringKey("replica_set") + Request = requestKey("http.request") + Response = responseKey("http.response") + ResponsibleIP = stringKey("responsible_ip") + RouterIP = stringKey("router_ip") + ScaleTargetCPUMillicores = uint32Key("scale_target_cpu_millicores") + ScaleTargetInFlightRequests = uint32Key("scale_target_in_flight_requests") + ScaleTargetMemoryMebibytes = uint32Key("scale_target_memory_mebibytes") + TargetCPUUsageMilli = uint32Key("target_cpu_usage_milli") + TargetInFlightRequests = uint32Key("target_in_flight_requests") + TargetMemoryUsageMiB = uint32Key("target_memory_usage_mib") + Tenant = stringKey("tenant") + Timestamp = timeKey("timestamp") + Token = newNames("token") + URL = urlKey("url") + UnclampedDesiredInstances = uint32Key("unclamped_desired_instances") + UnreadyInstances = intKey("unready_instances") + Zone = stringKey("zone") + ZoneMatch = stringKey("zone_match") ) diff --git a/internal/key/keys_test.go b/internal/key/keys_test.go index c7146c51..d65f108e 100644 --- a/internal/key/keys_test.go +++ b/internal/key/keys_test.go @@ -62,6 +62,9 @@ func TestKeys(t *testing.T) { key.RouterIP.Attr(fakeString), skipper.ScaleKey.Attr(fakeScale), skipper.ScaleDecisionKey.Attr(fakeScaleDecision), + key.ScaleTargetCPUMillicores.Attr(fakeUint32), + key.ScaleTargetInFlightRequests.Attr(fakeUint32), + key.ScaleTargetMemoryMebibytes.Attr(fakeUint32), key.TargetCPUUsageMilli.Attr(fakeUint32), key.TargetInFlightRequests.Attr(fakeUint32), key.TargetMemoryUsageMiB.Attr(fakeUint32), diff --git a/internal/key/testdata/keys.golden b/internal/key/testdata/keys.golden index 070dbdff..bb7d697a 100644 --- a/internal/key/testdata/keys.golden +++ b/internal/key/testdata/keys.golden @@ -11,6 +11,9 @@ "assignment.scale.target_cpu_usage_milli": 42, "assignment.scale.target_in_flight_requests": 42, "assignment.scale.target_memory_usage_mib": 42, + "assignment.scale_target_cpu_millicores": 42, + "assignment.scale_target_in_flight_requests": 42, + "assignment.scale_target_memory_mebibytes": 42, "assignment.tenant": "test", "attempt": 42, "count": 42, @@ -32,6 +35,9 @@ "function.scale.target_cpu_usage_milli": 42, "function.scale.target_in_flight_requests": 42, "function.scale.target_memory_usage_mib": 42, + "function.scale_target_cpu_millicores": 42, + "function.scale_target_in_flight_requests": 42, + "function.scale_target_memory_mebibytes": 42, "function.tenant": "test", "get_instance_duration_ms": 100, "heartbeat.in_flight_requests": 42, @@ -94,6 +100,9 @@ "scale_decision.metrics.test": 1.5, "scale_decision.reason": "SCALE_REASON_CPU", "scale_decision.unclamped_desired_instances": 42, + "scale_target_cpu_millicores": 42, + "scale_target_in_flight_requests": 42, + "scale_target_memory_mebibytes": 42, "target_cpu_usage_milli": 42, "target_in_flight_requests": 42, "target_memory_usage_mib": 42, @@ -123,6 +132,9 @@ "target_in_flight_requests": 42, "target_memory_usage_mib": 42 }, + "scale_target_cpu_millicores": 42, + "scale_target_in_flight_requests": 42, + "scale_target_memory_mebibytes": 42, "tenant": "test" }, "attempt": 42, @@ -148,6 +160,9 @@ "target_in_flight_requests": 42, "target_memory_usage_mib": 42 }, + "scale_target_cpu_millicores": 42, + "scale_target_in_flight_requests": 42, + "scale_target_memory_mebibytes": 42, "tenant": "test" }, "get_instance_duration_ms": 100, @@ -237,6 +252,9 @@ "reason": "SCALE_REASON_CPU", "unclamped_desired_instances": 42 }, + "scale_target_cpu_millicores": 42, + "scale_target_in_flight_requests": 42, + "scale_target_memory_mebibytes": 42, "target_cpu_usage_milli": 42, "target_in_flight_requests": 42, "target_memory_usage_mib": 42, diff --git a/internal/router/policy_wiring_test.go b/internal/router/policy_wiring_test.go new file mode 100644 index 00000000..b0df8718 --- /dev/null +++ b/internal/router/policy_wiring_test.go @@ -0,0 +1,217 @@ +package router + +import ( + "context" + "errors" + "net" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + "github.com/gadget-inc/skipper/internal/fixture" + "github.com/gadget-inc/skipper/internal/skipper" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/durationpb" + "gotest.tools/v3/assert" +) + +// TestRetryMaxAttemptsOverride confirms the router's RoundTrip retry loop +// honors a per-tenant retry_max_attempts override. +func TestRetryMaxAttemptsOverride(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + clusterMax int + assignment func() *skipper.Assignment + wantAttemptCount int32 + wantStatus int + }{ + { + name: "cluster default applies when assignment unset", + clusterMax: 3, + assignment: func() *skipper.Assignment { + return retryAssignment(t, nil) + }, + wantAttemptCount: 3, + wantStatus: http.StatusBadGateway, + }, + { + name: "tenant override below cluster default reduces attempts", + clusterMax: 5, + assignment: func() *skipper.Assignment { + return retryAssignment(t, func(b *skipper.Assignment_builder) { + b.RetryMaxAttempts = proto.Uint32(2) + }) + }, + wantAttemptCount: 2, + wantStatus: http.StatusBadGateway, + }, + { + name: "tenant override above cluster default raises attempts", + clusterMax: 2, + assignment: func() *skipper.Assignment { + return retryAssignment(t, func(b *skipper.Assignment_builder) { + b.RetryMaxAttempts = proto.Uint32(4) + }) + }, + wantAttemptCount: 4, + wantStatus: http.StatusBadGateway, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + fn := tc.assignment() + + mcc := fixture.NewMockControllerClient(t) + var attempts atomic.Int32 + mcc.HandleInstance(func(ctx context.Context, fn *skipper.Assignment, excludeInstanceNames ...string) (*skipper.Instance, error) { + attempts.Add(1) + inst := skipper.Instance_builder{ + Assignment: fn, + Name: new("failing-instance"), + Addr: new("127.0.0.1:1"), // unroutable + }.Build() + return inst, nil + }) + + cfg := testConfig() + cfg.MaxRoundTripAttempts = tc.clusterMax + // Keep the backoff loop snappy so the test stays fast. + cfg.RoundTripRetryMinTimeout = 1 * time.Millisecond + cfg.RoundTripRetryMaxTimeout = 5 * time.Millisecond + router := New(cfg, mcc) + router.roundTripper = roundTripperFunc(func(req *http.Request) (*http.Response, error) { + return nil, &net.OpError{Op: "dial", Err: errors.New("synthetic dial error")} + }) + + rw := httptest.NewRecorder() + req := fixture.NewAssignmentRequest(t, fn, http.MethodGet, "/", nil) + router.ServeHTTP(rw, req) + + assert.Equal(t, rw.Code, tc.wantStatus) + assert.Equal(t, attempts.Load(), tc.wantAttemptCount) + }) + } +} + +// TestRetryBackoffOverride confirms the router's backoff timer honors a +// per-tenant retry_min_backoff override by observing total request latency +// over multiple attempts. +func TestRetryBackoffOverride(t *testing.T) { + t.Parallel() + + // A tenant who sets a long min_backoff should observe a noticeably longer + // total retry duration than a tenant whose request uses the (small) cluster + // default. We compare the slow-tenant latency against a generous lower + // bound rather than the fast-tenant latency to keep the test stable on + // loaded CI runners. + mcc := fixture.NewMockControllerClient(t) + mcc.HandleInstance(func(ctx context.Context, fn *skipper.Assignment, excludeInstanceNames ...string) (*skipper.Instance, error) { + return skipper.Instance_builder{ + Assignment: fn, + Name: new("failing-instance"), + Addr: new("127.0.0.1:1"), + }.Build(), nil + }) + + cfg := testConfig() + cfg.MaxRoundTripAttempts = 3 + cfg.RoundTripRetryMinTimeout = 1 * time.Millisecond + cfg.RoundTripRetryMaxTimeout = 5 * time.Millisecond + router := New(cfg, mcc) + router.roundTripper = roundTripperFunc(func(req *http.Request) (*http.Response, error) { + return nil, &net.OpError{Op: "dial", Err: errors.New("synthetic dial error")} + }) + + // Tenant override: 50ms min, 200ms max — backoff totals across 2 retry + // gaps should be at least 100ms even on the fastest hardware. + overrideFn := retryAssignment(t, func(b *skipper.Assignment_builder) { + b.RetryMaxAttempts = proto.Uint32(3) + b.RetryMinBackoff = durationpb.New(50 * time.Millisecond) + b.RetryMaxBackoff = durationpb.New(200 * time.Millisecond) + }) + + start := time.Now() + rw := httptest.NewRecorder() + req := fixture.NewAssignmentRequest(t, overrideFn, http.MethodGet, "/", nil) + router.ServeHTTP(rw, req) + elapsed := time.Since(start) + + assert.Equal(t, rw.Code, http.StatusBadGateway) + assert.Assert(t, elapsed >= 100*time.Millisecond, + "3-attempt run with 50ms min backoff should take at least 100ms; got %s", elapsed) +} + +// TestPlaceholderKnobsNoEffectOnRouter confirms that setting placeholder +// knobs (transport_*, zone_*, retry_backpressure, retry_status_codes, +// heartbeat_interval, assign_path) leaves router routing decisions identical +// to those for an assignment that did not set them. +func TestPlaceholderKnobsNoEffectOnRouter(t *testing.T) { + t.Parallel() + + mcc := fixture.NewMockControllerClient(t) + + successInstance := func(t *testing.T, fn *skipper.Assignment) *skipper.Instance { + return fixture.NewInstance(t, fn, func(rw http.ResponseWriter, req *http.Request) { + rw.WriteHeader(http.StatusOK) + rw.Write([]byte("ok")) + }) + } + + mcc.HandleInstance(func(ctx context.Context, fn *skipper.Assignment, excludeInstanceNames ...string) (*skipper.Instance, error) { + return successInstance(t, fn), nil + }) + + cfg := testConfig() + router := New(cfg, mcc) + + baseFn := retryAssignment(t, nil) + withPlaceholders := retryAssignment(t, func(b *skipper.Assignment_builder) { + b.ZoneSpread = skipper.ZoneSpread_ZONE_SPREAD_REQUIRED.Enum() + b.ZoneMin = proto.Uint32(3) + b.ZoneAffinity = skipper.ZoneAffinity_ZONE_AFFINITY_REQUIRED.Enum() + b.AssignPath = new("/__skipper/v2/assign") + b.HeartbeatInterval = durationpb.New(7 * time.Second) + b.RetryBackpressure = skipper.Backpressure_BACKPRESSURE_RETRY_AND_EJECT.Enum() + b.RetryStatusCodes = []uint32{503, 504, 529} + b.TransportDialTimeout = durationpb.New(100 * time.Millisecond) + b.TransportKeepalive = durationpb.New(30 * time.Second) + b.TransportIdleConnTimeout = durationpb.New(90 * time.Second) + b.TransportTlsHandshakeTimeout = durationpb.New(10 * time.Second) + b.TransportMaxIdleConns = proto.Uint32(200) + b.TransportForceHttp2 = new(true) + b.TransportDisableCompression = new(true) + b.TransportFlushInterval = durationpb.New(100 * time.Millisecond) + }) + + for _, fn := range []*skipper.Assignment{baseFn, withPlaceholders} { + rw := httptest.NewRecorder() + req := fixture.NewAssignmentRequest(t, fn, http.MethodGet, "/", nil) + router.ServeHTTP(rw, req) + assert.Equal(t, rw.Code, http.StatusOK) + assert.Equal(t, rw.Body.String(), "ok") + } +} + +func retryAssignment(t *testing.T, modify func(b *skipper.Assignment_builder)) *skipper.Assignment { + t.Helper() + b := skipper.Assignment_builder{ + Namespace: new("ns"), + Deployment: new("deploy"), + Tenant: new("tenant"), + Scale: skipper.Scale_builder{ + MinInstances: proto.Uint32(1), + MaxInstances: proto.Uint32(10), + }.Build(), + } + if modify != nil { + modify(&b) + } + return b.Build() +} diff --git a/internal/router/router.go b/internal/router/router.go index e85ae052..b4244b8b 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -197,21 +197,25 @@ func (r *Router) RoundTrip(req *http.Request) (*http.Response, error) { defer func() { req.Body = originalBody }() } + maxAttempts := fn.RetryMaxAttempts(r.config.MaxRoundTripAttempts) + minBackoff := fn.RetryMinBackoff(r.config.RoundTripRetryMinTimeout) + maxBackoff := fn.RetryMaxBackoff(r.config.RoundTripRetryMaxTimeout) + var excludedInstanceNames []string getInstanceDuration := time.Duration(0) attempt := 0 for { attempt++ - if attempt > r.config.MaxRoundTripAttempts { - return nil, fmt.Errorf("failed to proxy request after %d attempts", r.config.MaxRoundTripAttempts) + if attempt > maxAttempts { + return nil, fmt.Errorf("failed to proxy request after %d attempts", maxAttempts) } if attempt > 1 { select { case <-req.Context().Done(): return nil, req.Context().Err() - case <-time.After(r.calculateBackoff(attempt)): + case <-time.After(calculateBackoff(attempt, minBackoff, maxBackoff)): } } @@ -343,9 +347,12 @@ func (r *Router) releaseInstance(inst *skipper.Instance) { }() } -func (r *Router) calculateBackoff(attempt int) time.Duration { - minTimeout := float64(r.config.RoundTripRetryMinTimeout) - maxTimeout := float64(r.config.RoundTripRetryMaxTimeout) +// calculateBackoff is the per-attempt retry backoff calculation. min and max +// are resolved by the caller, allowing the router's per-request hot path to +// hoist the per-assignment overrides out of the loop body. +func calculateBackoff(attempt int, minBackoff, maxBackoff time.Duration) time.Duration { + minTimeout := float64(minBackoff) + maxTimeout := float64(maxBackoff) factor := 1 + rand.Float64() // randomize the factor between 1 and 2 to add jitter return time.Duration(min(factor*minTimeout*math.Pow(2, float64(attempt)), maxTimeout)) } diff --git a/internal/router/router_test.go b/internal/router/router_test.go index 92679acd..3ce78d77 100644 --- a/internal/router/router_test.go +++ b/internal/router/router_test.go @@ -976,14 +976,9 @@ func TestCalculateBackoff(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - cfg := testConfig() - cfg.RoundTripRetryMinTimeout = tc.minTimeout - cfg.RoundTripRetryMaxTimeout = tc.maxTimeout - router := New(cfg, fixture.NewMockControllerClient(t)) - // Run multiple times due to randomness for range 100 { - backoff := router.calculateBackoff(tc.attempt) + backoff := calculateBackoff(tc.attempt, tc.minTimeout, tc.maxTimeout) assert.Assert(t, backoff >= tc.checkMin, "attempt %d: backoff %v < min %v", tc.attempt, backoff, tc.checkMin) assert.Assert(t, backoff <= tc.checkMax, "attempt %d: backoff %v > max %v", tc.attempt, backoff, tc.checkMax) } @@ -2237,10 +2232,8 @@ func TestRequestDrainingDuringInstanceFetch(t *testing.T) { func TestBackoffDoesNotOverflowAtHighAttempts(t *testing.T) { t.Parallel() - cfg := testConfig() - cfg.RoundTripRetryMinTimeout = 100 * time.Millisecond - cfg.RoundTripRetryMaxTimeout = 5 * time.Second - router := New(cfg, fixture.NewMockControllerClient(t)) + minBackoff := 100 * time.Millisecond + maxBackoff := 5 * time.Second // Test various high attempt numbers that could cause overflow testAttempts := []int{10, 50, 100, 1000, 10000} @@ -2248,11 +2241,11 @@ func TestBackoffDoesNotOverflowAtHighAttempts(t *testing.T) { for _, attempt := range testAttempts { // Run multiple times due to randomness in backoff calculation for range 10 { - backoff := router.calculateBackoff(attempt) + backoff := calculateBackoff(attempt, minBackoff, maxBackoff) // Verify backoff is within valid range assert.Assert(t, backoff >= 0, "attempt %d: backoff should not be negative: %v", attempt, backoff) - assert.Assert(t, backoff <= cfg.RoundTripRetryMaxTimeout, "attempt %d: backoff %v exceeds max %v", attempt, backoff, cfg.RoundTripRetryMaxTimeout) + assert.Assert(t, backoff <= maxBackoff, "attempt %d: backoff %v exceeds max %v", attempt, backoff, maxBackoff) } } } diff --git a/internal/skipper/assignment.go b/internal/skipper/assignment.go index b5104d29..46ba11a3 100644 --- a/internal/skipper/assignment.go +++ b/internal/skipper/assignment.go @@ -5,6 +5,7 @@ import ( "fmt" "log/slog" "net/http" + "time" "github.com/cespare/xxhash/v2" "github.com/gadget-inc/skipper/internal/key" @@ -76,7 +77,27 @@ func (a *Assignment) LogValue() slog.Value { key.Tenant.Slog(a.GetTenant()), key.Metadata.Slog(a.GetMetadata()), key.Oneshot.Slog(a.GetOneshot()), - ScaleKey.Slog(a.GetScale()), + slog.Attr{Key: ScaleKey.Name, Value: a.resolvedScaleValue()}, + // Flat-form companions for the three target_* fields. Each pair + // reports the same resolved value (flat-preferred-over-Scale) so + // dashboards keying on either vocabulary observe identical series. + // The cleanup plan drops the legacy `scale.target_*` emissions. + key.ScaleTargetCPUMillicores.Slog(a.ScaleTargetCPUMillicores()), + key.ScaleTargetMemoryMebibytes.Slog(a.ScaleTargetMemoryMebibytes()), + key.ScaleTargetInFlightRequests.Slog(a.ScaleTargetInFlightRequests()), + ) +} + +// resolvedScaleValue builds the slog group emitted under the `scale` key, +// reading each subfield through the resolvers so flat-preferred-over-Scale +// resolution applies before logging. +func (a *Assignment) resolvedScaleValue() slog.Value { + return slog.GroupValue( + key.MinInstances.Slog(a.ScaleMinInstances()), + key.MaxInstances.Slog(a.ScaleMaxInstances()), + key.TargetCPUUsageMilli.Slog(a.ScaleTargetCPUMillicores()), + key.TargetMemoryUsageMiB.Slog(a.ScaleTargetMemoryMebibytes()), + key.TargetInFlightRequests.Slog(a.ScaleTargetInFlightRequests()), ) } @@ -90,19 +111,87 @@ func (a *Assignment) Validate() error { if a.GetTenant() == "" { return errors.New("missing tenant") } - scale := a.GetScale() - if scale == nil { + + // Resolve min/max across flat scale_* fields and the nested Scale sub-message + // so a hybrid tenant header (e.g. scale.min_instances + scale_max_instances) + // is validated against the same value the runtime resolvers see. + maxInst, hasMax := a.resolvedScaleMax() + if !hasMax { return errors.New("missing scale") } - if scale.GetMaxInstances() < 1 { + if maxInst < 1 { return errors.New("scale.max_instances must be >= 1") } - if scale.GetMinInstances() > scale.GetMaxInstances() { - return fmt.Errorf("scale.min_instances (%d) must be <= scale.max_instances (%d)", scale.GetMinInstances(), scale.GetMaxInstances()) + if minInst, hasMin := a.resolvedScaleMin(); hasMin && minInst > maxInst { + return fmt.Errorf("scale.min_instances (%d) must be <= scale.max_instances (%d)", minInst, maxInst) + } + + if a.HasScaleTolerance() && a.GetScaleTolerance() < 0 { + return fmt.Errorf("scale_tolerance (%v) must be >= 0", a.GetScaleTolerance()) + } + + // Durations must be non-negative. proto3 admits negative durations because + // google.protobuf.Duration is a (seconds, nanos) pair; reject them at parse. + for _, d := range nonNegativeDurations(a) { + if d.has && d.value < 0 { + return fmt.Errorf("%s (%s) must be >= 0", d.name, d.value) + } + } + + // Degenerate-zero rejection: a value of zero would deadlock the decision + // site (heartbeat scale-to-zero with zero timeout, retry loop with zero + // attempts, etc.). + if a.HasHeartbeatTimeout() && a.GetHeartbeatTimeout().AsDuration() == 0 { + return errors.New("heartbeat_timeout must be > 0") + } + if a.HasAssignTimeout() && a.GetAssignTimeout().AsDuration() == 0 { + return errors.New("assign_timeout must be > 0") + } + if a.HasAssignTokenTtl() && a.GetAssignTokenTtl().AsDuration() == 0 { + return errors.New("assign_token_ttl must be > 0") + } + if a.HasRetryMaxAttempts() && a.GetRetryMaxAttempts() == 0 { + return errors.New("retry_max_attempts must be > 0") + } + + if a.HasRetryMinBackoff() && a.HasRetryMaxBackoff() { + minBackoff := a.GetRetryMinBackoff().AsDuration() + maxBackoff := a.GetRetryMaxBackoff().AsDuration() + if minBackoff > maxBackoff { + return fmt.Errorf("retry_min_backoff (%s) must be <= retry_max_backoff (%s)", minBackoff, maxBackoff) + } } + return nil } +type namedDuration struct { + name string + has bool + value time.Duration +} + +// nonNegativeDurations returns every duration-typed policy field with its +// presence bit and resolved value, so Validate can scan for negative values +// in one place. Fields list mirrors the duration knobs declared on Assignment. +func nonNegativeDurations(a *Assignment) []namedDuration { + return []namedDuration{ + {"scale_downscale_stabilization", a.HasScaleDownscaleStabilization(), a.GetScaleDownscaleStabilization().AsDuration()}, + {"scale_initial_readiness_delay", a.HasScaleInitialReadinessDelay(), a.GetScaleInitialReadinessDelay().AsDuration()}, + {"assign_timeout", a.HasAssignTimeout(), a.GetAssignTimeout().AsDuration()}, + {"assign_token_ttl", a.HasAssignTokenTtl(), a.GetAssignTokenTtl().AsDuration()}, + {"heartbeat_interval", a.HasHeartbeatInterval(), a.GetHeartbeatInterval().AsDuration()}, + {"heartbeat_timeout", a.HasHeartbeatTimeout(), a.GetHeartbeatTimeout().AsDuration()}, + {"retry_min_backoff", a.HasRetryMinBackoff(), a.GetRetryMinBackoff().AsDuration()}, + {"retry_max_backoff", a.HasRetryMaxBackoff(), a.GetRetryMaxBackoff().AsDuration()}, + {"transport_dial_timeout", a.HasTransportDialTimeout(), a.GetTransportDialTimeout().AsDuration()}, + {"transport_keepalive", a.HasTransportKeepalive(), a.GetTransportKeepalive().AsDuration()}, + {"transport_idle_conn_timeout", a.HasTransportIdleConnTimeout(), a.GetTransportIdleConnTimeout().AsDuration()}, + {"transport_tls_handshake_timeout", a.HasTransportTlsHandshakeTimeout(), a.GetTransportTlsHandshakeTimeout().AsDuration()}, + {"transport_flush_interval", a.HasTransportFlushInterval(), a.GetTransportFlushInterval().AsDuration()}, + } +} + // SetHeader serializes the assignment as JSON and writes it under both the // legacy ("X-Skipper-Function") and canonical ("X-Skipper-Assignment") header // names. Receivers that read either name accept the body. The cleanup plan diff --git a/internal/skipper/json_test.go b/internal/skipper/json_test.go index db7c99d0..5a6a2afd 100644 --- a/internal/skipper/json_test.go +++ b/internal/skipper/json_test.go @@ -7,6 +7,7 @@ import ( "github.com/go-json-experiment/json" "github.com/go-json-experiment/json/jsontext" "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/durationpb" "google.golang.org/protobuf/types/known/timestamppb" "gotest.tools/v3/assert" "gotest.tools/v3/golden" @@ -104,3 +105,99 @@ func TestJSONUnmarshal(t *testing.T) { assert.Assert(t, proto.Equal(result.ScaleDecision, goldenScaleDecision), "scale decision mismatch") assert.Assert(t, proto.Equal(result.ScaleMetric, goldenScaleMetric), "scale metric mismatch") } + +// TestJSONFlatPolicyRoundtrip exercises the full flat policy surface +// (29 fields) plus the three enums, ensuring marshal/unmarshal preserves +// every wired and placeholder knob a tenant can set in the header. +func TestJSONFlatPolicyRoundtrip(t *testing.T) { + t.Parallel() + + full := Assignment_builder{ + Namespace: new("ns"), + Deployment: new("deploy"), + Tenant: new("tenant"), + Metadata: new("opaque"), + Oneshot: new(true), + ScaleMinInstances: proto.Uint32(2), + ScaleMaxInstances: proto.Uint32(20), + ScaleTargetCpuMillicores: proto.Uint32(500), + ScaleTargetMemoryMebibytes: proto.Uint32(256), + ScaleTargetInFlightRequests: proto.Uint32(100), + ScaleTolerance: new(0.15), + ScaleDownscaleStabilization: durationpb.New(2 * time.Minute), + ScaleInitialReadinessDelay: durationpb.New(15 * time.Second), + ZoneSpread: ZoneSpread_ZONE_SPREAD_PREFERRED.Enum(), + ZoneMin: proto.Uint32(2), + ZoneAffinity: ZoneAffinity_ZONE_AFFINITY_PREFERRED.Enum(), + AssignPath: new("/__skipper/assign-v2"), + AssignTimeout: durationpb.New(45 * time.Second), + AssignTokenTtl: durationpb.New(2 * time.Hour), + HeartbeatInterval: durationpb.New(7 * time.Second), + HeartbeatTimeout: durationpb.New(120 * time.Second), + RetryMaxAttempts: proto.Uint32(8), + RetryMinBackoff: durationpb.New(150 * time.Millisecond), + RetryMaxBackoff: durationpb.New(10 * time.Second), + RetryBackpressure: Backpressure_BACKPRESSURE_RETRY.Enum(), + RetryStatusCodes: []uint32{503, 504, 529}, + TransportDialTimeout: durationpb.New(500 * time.Millisecond), + TransportKeepalive: durationpb.New(30 * time.Second), + TransportIdleConnTimeout: durationpb.New(90 * time.Second), + TransportTlsHandshakeTimeout: durationpb.New(10 * time.Second), + TransportMaxIdleConns: proto.Uint32(100), + TransportForceHttp2: new(true), + TransportDisableCompression: new(true), + TransportFlushInterval: durationpb.New(100 * time.Millisecond), + }.Build() + + body, err := json.Marshal(full) + assert.NilError(t, err) + + var got Assignment + err = json.Unmarshal(body, &got) + assert.NilError(t, err) + assert.Assert(t, proto.Equal(&got, full), "full flat-policy assignment did not roundtrip") +} + +// TestJSONFlatScaleHybrid covers the three shapes a tenant can use for scale: +// nested only, flat only, and both — confirming that JSON parses each into +// distinct presence bits and that resolvers see flat-wins on conflict. +func TestJSONFlatScaleHybrid(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + wantMin uint32 + wantMax uint32 + wantCPU uint32 + }{ + { + name: "nested only", + input: `{"namespace":"n","deployment":"d","tenant":"t","scale":{"min_instances":1,"max_instances":10,"target_cpu_usage_milli":500}}`, + wantMin: 1, wantMax: 10, wantCPU: 500, + }, + { + name: "flat only", + input: `{"namespace":"n","deployment":"d","tenant":"t","scale_min_instances":2,"scale_max_instances":20,"scale_target_cpu_millicores":600}`, + wantMin: 2, wantMax: 20, wantCPU: 600, + }, + { + name: "both, flat wins", + input: `{"namespace":"n","deployment":"d","tenant":"t","scale":{"min_instances":1,"max_instances":10,"target_cpu_usage_milli":500},"scale_min_instances":3,"scale_max_instances":30,"scale_target_cpu_millicores":700}`, + wantMin: 3, wantMax: 30, wantCPU: 700, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + var a Assignment + err := json.Unmarshal([]byte(tc.input), &a) + assert.NilError(t, err) + assert.Equal(t, a.ScaleMinInstances(), tc.wantMin) + assert.Equal(t, a.ScaleMaxInstances(), tc.wantMax) + assert.Equal(t, a.ScaleTargetCPUMillicores(), tc.wantCPU) + }) + } +} diff --git a/internal/skipper/policy.go b/internal/skipper/policy.go new file mode 100644 index 00000000..38460af7 --- /dev/null +++ b/internal/skipper/policy.go @@ -0,0 +1,343 @@ +package skipper + +import "time" + +// This file declares the per-tenant policy resolvers on *Assignment. Each +// resolver gives a single edit point where a decision site can pull a knob +// from the tenant-supplied assignment, falling back to a cluster default +// (passed as an argument) when the tenant did not override it. +// +// Resolver semantics: +// +// - Hybrid scale fields (the five flat fields with a Scale sub-message +// twin): resolvedScaleX returns (value, present). Public resolvers +// prefer the flat field, then fall back to Scale. Cluster defaults +// do not apply -- scale.min/max/target_* originate from the header. +// - Wired non-scale fields: prefer the explicit tenant value (proto3 +// Has*() returns true), else fall back to the cluster default arg. +// - Placeholder fields: same resolver shape so a followup plan wiring +// the knob has a single edit point. No decision site reads these in +// the current release. + +// resolvedScaleMin returns the resolved minimum-instances value across the +// flat scale_min_instances field and the nested Scale sub-message, preferring +// flat. The bool reports whether either form provided a value. +func (a *Assignment) resolvedScaleMin() (uint32, bool) { + if a.HasScaleMinInstances() { + return a.GetScaleMinInstances(), true + } + if scale := a.GetScale(); scale != nil && scale.HasMinInstances() { + return scale.GetMinInstances(), true + } + return 0, false +} + +// resolvedScaleMax returns the resolved maximum-instances value across flat +// and nested. The bool reports whether either form provided a value. +func (a *Assignment) resolvedScaleMax() (uint32, bool) { + if a.HasScaleMaxInstances() { + return a.GetScaleMaxInstances(), true + } + if scale := a.GetScale(); scale != nil && scale.HasMaxInstances() { + return scale.GetMaxInstances(), true + } + return 0, false +} + +// resolvedScaleTargetCPUMillicores resolves the per-instance CPU target +// across the flat field and the nested target_cpu_usage_milli field. +func (a *Assignment) resolvedScaleTargetCPUMillicores() (uint32, bool) { + if a.HasScaleTargetCpuMillicores() { + return a.GetScaleTargetCpuMillicores(), true + } + if scale := a.GetScale(); scale != nil && scale.HasTargetCpuUsageMilli() { + return scale.GetTargetCpuUsageMilli(), true + } + return 0, false +} + +// resolvedScaleTargetMemoryMebibytes resolves the per-instance memory target +// across the flat field and the nested target_memory_usage_mib field. +func (a *Assignment) resolvedScaleTargetMemoryMebibytes() (uint32, bool) { + if a.HasScaleTargetMemoryMebibytes() { + return a.GetScaleTargetMemoryMebibytes(), true + } + if scale := a.GetScale(); scale != nil && scale.HasTargetMemoryUsageMib() { + return scale.GetTargetMemoryUsageMib(), true + } + return 0, false +} + +// resolvedScaleTargetInFlightRequests resolves the per-instance in-flight +// request target across the flat field and the nested +// target_in_flight_requests field. +func (a *Assignment) resolvedScaleTargetInFlightRequests() (uint32, bool) { + if a.HasScaleTargetInFlightRequests() { + return a.GetScaleTargetInFlightRequests(), true + } + if scale := a.GetScale(); scale != nil && scale.HasTargetInFlightRequests() { + return scale.GetTargetInFlightRequests(), true + } + return 0, false +} + +// ScaleMinInstances returns the assignment's resolved minimum-instances value. +// Prefers the flat scale_min_instances field, falls back to scale.min_instances. +func (a *Assignment) ScaleMinInstances() uint32 { + v, _ := a.resolvedScaleMin() + return v +} + +// ScaleMaxInstances returns the assignment's resolved maximum-instances value. +// Prefers the flat scale_max_instances field, falls back to scale.max_instances. +func (a *Assignment) ScaleMaxInstances() uint32 { + v, _ := a.resolvedScaleMax() + return v +} + +// ScaleTargetCPUMillicores returns the assignment's resolved per-instance CPU +// target in millicores. Prefers the flat scale_target_cpu_millicores field, +// falls back to scale.target_cpu_usage_milli. +func (a *Assignment) ScaleTargetCPUMillicores() uint32 { + v, _ := a.resolvedScaleTargetCPUMillicores() + return v +} + +// ScaleTargetMemoryMebibytes returns the assignment's resolved per-instance +// memory target in mebibytes. Prefers the flat scale_target_memory_mebibytes +// field, falls back to scale.target_memory_usage_mib. +func (a *Assignment) ScaleTargetMemoryMebibytes() uint32 { + v, _ := a.resolvedScaleTargetMemoryMebibytes() + return v +} + +// ScaleTargetInFlightRequests returns the assignment's resolved per-instance +// in-flight request target. Prefers the flat field, falls back to the nested +// scale.target_in_flight_requests. +func (a *Assignment) ScaleTargetInFlightRequests() uint32 { + v, _ := a.resolvedScaleTargetInFlightRequests() + return v +} + +// ScaleTolerance returns the HPA usage-ratio tolerance. Returns the tenant +// override if scale_tolerance is set, else the cluster default. +func (a *Assignment) ScaleTolerance(cluster float64) float64 { + if a.HasScaleTolerance() { + return a.GetScaleTolerance() + } + return cluster +} + +// ScaleDownscaleStabilization returns the downscale stabilization window. +// Returns the tenant override if scale_downscale_stabilization is set, else +// the cluster default. +func (a *Assignment) ScaleDownscaleStabilization(cluster time.Duration) time.Duration { + if a.HasScaleDownscaleStabilization() { + return a.GetScaleDownscaleStabilization().AsDuration() + } + return cluster +} + +// ScaleInitialReadinessDelay returns the per-pod initial-readiness delay +// used by the HPA's CPU metric. Returns the tenant override if set, else the +// cluster default. +func (a *Assignment) ScaleInitialReadinessDelay(cluster time.Duration) time.Duration { + if a.HasScaleInitialReadinessDelay() { + return a.GetScaleInitialReadinessDelay().AsDuration() + } + return cluster +} + +// ZoneSpread returns the resolved zone-spread mode. Placeholder (followup: +// zone-aware-placement); no decision site reads this in the current release. +func (a *Assignment) ZoneSpread() ZoneSpread { + return a.GetZoneSpread() +} + +// ZoneMin returns the minimum number of zones the controller should spread +// instances across. Placeholder (followup: zone-aware-placement); no decision +// site reads this in the current release. +func (a *Assignment) ZoneMin() uint32 { + return a.GetZoneMin() +} + +// ZoneAffinity returns the resolved zone-affinity mode used by the router. +// Placeholder (followup: zone-aware-affinity); no decision site reads this +// in the current release. +func (a *Assignment) ZoneAffinity() ZoneAffinity { + return a.GetZoneAffinity() +} + +// AssignPath returns the per-assignment override of the controller's assign +// POST path. Placeholder (followup: per-assignment-assign-path); the +// controller does not read this in the current release. Returns the cluster +// default when unset. +func (a *Assignment) AssignPath(cluster string) string { + if a.HasAssignPath() { + return a.GetAssignPath() + } + return cluster +} + +// AssignTimeout returns the per-assignment override of the controller's +// assign POST timeout, falling back to the cluster default when unset. +func (a *Assignment) AssignTimeout(cluster time.Duration) time.Duration { + if a.HasAssignTimeout() { + return a.GetAssignTimeout().AsDuration() + } + return cluster +} + +// AssignTokenTTL returns the per-assignment override of the PASETO token's +// time-to-live, falling back to the cluster default when unset. +func (a *Assignment) AssignTokenTTL(cluster time.Duration) time.Duration { + if a.HasAssignTokenTtl() { + return a.GetAssignTokenTtl().AsDuration() + } + return cluster +} + +// HeartbeatInterval returns the per-assignment override of the router's +// heartbeat interval. Placeholder (followup: per-assignment-heartbeat- +// interval); no decision site reads this in the current release. Returns the +// cluster default when unset. +func (a *Assignment) HeartbeatInterval(cluster time.Duration) time.Duration { + if a.HasHeartbeatInterval() { + return a.GetHeartbeatInterval().AsDuration() + } + return cluster +} + +// HeartbeatTimeout returns the per-assignment override of the controller's +// heartbeat timeout used for scale-to-zero and pod-cleanup decisions, +// falling back to the cluster default when unset. +func (a *Assignment) HeartbeatTimeout(cluster time.Duration) time.Duration { + if a.HasHeartbeatTimeout() { + return a.GetHeartbeatTimeout().AsDuration() + } + return cluster +} + +// RetryMaxAttempts returns the per-assignment override of the router's +// max round-trip attempts. Returns the tenant override if retry_max_attempts +// is set, else the cluster default. +func (a *Assignment) RetryMaxAttempts(cluster int) int { + if a.HasRetryMaxAttempts() { + return int(a.GetRetryMaxAttempts()) + } + return cluster +} + +// RetryMinBackoff returns the per-assignment override of the router's +// minimum retry backoff, falling back to the cluster default when unset. +func (a *Assignment) RetryMinBackoff(cluster time.Duration) time.Duration { + if a.HasRetryMinBackoff() { + return a.GetRetryMinBackoff().AsDuration() + } + return cluster +} + +// RetryMaxBackoff returns the per-assignment override of the router's +// maximum retry backoff, falling back to the cluster default when unset. +func (a *Assignment) RetryMaxBackoff(cluster time.Duration) time.Duration { + if a.HasRetryMaxBackoff() { + return a.GetRetryMaxBackoff().AsDuration() + } + return cluster +} + +// RetryBackpressure returns the per-assignment override of the router's +// backpressure policy. Placeholder (followup: backpressure); no decision +// site reads this in the current release. +func (a *Assignment) RetryBackpressure() Backpressure { + return a.GetRetryBackpressure() +} + +// RetryStatusCodes returns the per-assignment override of the HTTP status +// codes that should trigger a retry. Placeholder (followup: backpressure); +// no decision site reads this in the current release. +func (a *Assignment) RetryStatusCodes() []uint32 { + return a.GetRetryStatusCodes() +} + +// TransportDialTimeout returns the per-assignment override of the router +// transport's dial timeout. Placeholder (followup: per-assignment-transport); +// no decision site reads this in the current release. +func (a *Assignment) TransportDialTimeout(cluster time.Duration) time.Duration { + if a.HasTransportDialTimeout() { + return a.GetTransportDialTimeout().AsDuration() + } + return cluster +} + +// TransportKeepalive returns the per-assignment override of the router +// transport's keepalive interval. Placeholder (followup: per-assignment- +// transport); no decision site reads this in the current release. +func (a *Assignment) TransportKeepalive(cluster time.Duration) time.Duration { + if a.HasTransportKeepalive() { + return a.GetTransportKeepalive().AsDuration() + } + return cluster +} + +// TransportIdleConnTimeout returns the per-assignment override of the router +// transport's idle-connection timeout. Placeholder (followup: per-assignment- +// transport); no decision site reads this in the current release. +func (a *Assignment) TransportIdleConnTimeout(cluster time.Duration) time.Duration { + if a.HasTransportIdleConnTimeout() { + return a.GetTransportIdleConnTimeout().AsDuration() + } + return cluster +} + +// TransportTLSHandshakeTimeout returns the per-assignment override of the +// router transport's TLS handshake timeout. Placeholder (followup: +// per-assignment-transport); no decision site reads this in the current +// release. +func (a *Assignment) TransportTLSHandshakeTimeout(cluster time.Duration) time.Duration { + if a.HasTransportTlsHandshakeTimeout() { + return a.GetTransportTlsHandshakeTimeout().AsDuration() + } + return cluster +} + +// TransportMaxIdleConns returns the per-assignment override of the router +// transport's max idle connections. Placeholder (followup: per-assignment- +// transport); no decision site reads this in the current release. +func (a *Assignment) TransportMaxIdleConns(cluster int) int { + if a.HasTransportMaxIdleConns() { + return int(a.GetTransportMaxIdleConns()) + } + return cluster +} + +// TransportForceHTTP2 returns the per-assignment override of the router +// transport's force-HTTP/2 setting. Placeholder (followup: per-assignment- +// transport); no decision site reads this in the current release. +func (a *Assignment) TransportForceHTTP2(cluster bool) bool { + if a.HasTransportForceHttp2() { + return a.GetTransportForceHttp2() + } + return cluster +} + +// TransportDisableCompression returns the per-assignment override of the +// router transport's disable-compression setting. Placeholder (followup: +// per-assignment-transport); no decision site reads this in the current +// release. +func (a *Assignment) TransportDisableCompression(cluster bool) bool { + if a.HasTransportDisableCompression() { + return a.GetTransportDisableCompression() + } + return cluster +} + +// TransportFlushInterval returns the per-assignment override of the router +// reverse-proxy's response flush interval. Placeholder (followup: per- +// assignment-transport); no decision site reads this in the current release. +func (a *Assignment) TransportFlushInterval(cluster time.Duration) time.Duration { + if a.HasTransportFlushInterval() { + return a.GetTransportFlushInterval().AsDuration() + } + return cluster +} diff --git a/internal/skipper/policy_test.go b/internal/skipper/policy_test.go new file mode 100644 index 00000000..d27a3727 --- /dev/null +++ b/internal/skipper/policy_test.go @@ -0,0 +1,421 @@ +package skipper + +import ( + "testing" + "time" + + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/durationpb" + "gotest.tools/v3/assert" +) + +func TestScaleResolversFlatPreferred(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + fn *Assignment + wantMin uint32 + wantMax uint32 + wantTargetCPU uint32 + wantTargetMem uint32 + wantTargetInFlight uint32 + wantHasMin, wantHasMax bool + }{ + { + name: "nested only", + fn: Assignment_builder{ + Namespace: new("n"), + Deployment: new("d"), + Tenant: new("t"), + Scale: Scale_builder{ + MinInstances: proto.Uint32(1), + MaxInstances: proto.Uint32(10), + TargetCpuUsageMilli: proto.Uint32(500), + TargetMemoryUsageMib: proto.Uint32(256), + TargetInFlightRequests: proto.Uint32(100), + }.Build(), + }.Build(), + wantMin: 1, wantMax: 10, wantTargetCPU: 500, wantTargetMem: 256, wantTargetInFlight: 100, + wantHasMin: true, wantHasMax: true, + }, + { + name: "flat only", + fn: Assignment_builder{ + Namespace: new("n"), + Deployment: new("d"), + Tenant: new("t"), + ScaleMinInstances: proto.Uint32(2), + ScaleMaxInstances: proto.Uint32(20), + ScaleTargetCpuMillicores: proto.Uint32(600), + ScaleTargetMemoryMebibytes: proto.Uint32(512), + ScaleTargetInFlightRequests: proto.Uint32(200), + }.Build(), + wantMin: 2, wantMax: 20, wantTargetCPU: 600, wantTargetMem: 512, wantTargetInFlight: 200, + wantHasMin: true, wantHasMax: true, + }, + { + name: "both shapes, flat wins", + fn: Assignment_builder{ + Namespace: new("n"), + Deployment: new("d"), + Tenant: new("t"), + Scale: Scale_builder{ + MinInstances: proto.Uint32(1), + MaxInstances: proto.Uint32(10), + TargetCpuUsageMilli: proto.Uint32(500), + TargetMemoryUsageMib: proto.Uint32(256), + TargetInFlightRequests: proto.Uint32(100), + }.Build(), + ScaleMinInstances: proto.Uint32(3), + ScaleMaxInstances: proto.Uint32(30), + ScaleTargetCpuMillicores: proto.Uint32(700), + ScaleTargetMemoryMebibytes: proto.Uint32(1024), + ScaleTargetInFlightRequests: proto.Uint32(300), + }.Build(), + wantMin: 3, wantMax: 30, wantTargetCPU: 700, wantTargetMem: 1024, wantTargetInFlight: 300, + wantHasMin: true, wantHasMax: true, + }, + { + name: "hybrid: min from nested, max from flat", + fn: Assignment_builder{ + Namespace: new("n"), + Deployment: new("d"), + Tenant: new("t"), + Scale: Scale_builder{ + MinInstances: proto.Uint32(2), + }.Build(), + ScaleMaxInstances: proto.Uint32(15), + }.Build(), + wantMin: 2, wantMax: 15, + wantHasMin: true, wantHasMax: true, + }, + { + name: "neither shape present", + fn: Assignment_builder{ + Namespace: new("n"), + Deployment: new("d"), + Tenant: new("t"), + }.Build(), + wantHasMin: false, wantHasMax: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + gotMin, hasMin := tc.fn.resolvedScaleMin() + gotMax, hasMax := tc.fn.resolvedScaleMax() + + assert.Equal(t, hasMin, tc.wantHasMin, "resolvedScaleMin presence") + assert.Equal(t, hasMax, tc.wantHasMax, "resolvedScaleMax presence") + if tc.wantHasMin { + assert.Equal(t, gotMin, tc.wantMin) + assert.Equal(t, tc.fn.ScaleMinInstances(), tc.wantMin) + } + if tc.wantHasMax { + assert.Equal(t, gotMax, tc.wantMax) + assert.Equal(t, tc.fn.ScaleMaxInstances(), tc.wantMax) + } + + assert.Equal(t, tc.fn.ScaleTargetCPUMillicores(), tc.wantTargetCPU) + assert.Equal(t, tc.fn.ScaleTargetMemoryMebibytes(), tc.wantTargetMem) + assert.Equal(t, tc.fn.ScaleTargetInFlightRequests(), tc.wantTargetInFlight) + }) + } +} + +func TestDurationResolversFallback(t *testing.T) { + t.Parallel() + + cluster := 30 * time.Second + + tests := []struct { + name string + set *Assignment + resolve func(a *Assignment) time.Duration + wantUnset time.Duration + wantSet time.Duration + }{ + { + name: "HeartbeatTimeout", + set: Assignment_builder{ + Namespace: new("n"), Deployment: new("d"), Tenant: new("t"), + ScaleMaxInstances: proto.Uint32(1), + HeartbeatTimeout: durationpb.New(120 * time.Second), + }.Build(), + resolve: func(a *Assignment) time.Duration { return a.HeartbeatTimeout(cluster) }, + wantUnset: cluster, + wantSet: 120 * time.Second, + }, + { + name: "AssignTimeout", + set: Assignment_builder{ + Namespace: new("n"), Deployment: new("d"), Tenant: new("t"), + ScaleMaxInstances: proto.Uint32(1), + AssignTimeout: durationpb.New(45 * time.Second), + }.Build(), + resolve: func(a *Assignment) time.Duration { return a.AssignTimeout(cluster) }, + wantUnset: cluster, + wantSet: 45 * time.Second, + }, + { + name: "AssignTokenTTL", + set: Assignment_builder{ + Namespace: new("n"), Deployment: new("d"), Tenant: new("t"), + ScaleMaxInstances: proto.Uint32(1), + AssignTokenTtl: durationpb.New(1 * time.Hour), + }.Build(), + resolve: func(a *Assignment) time.Duration { return a.AssignTokenTTL(cluster) }, + wantUnset: cluster, + wantSet: 1 * time.Hour, + }, + { + name: "ScaleDownscaleStabilization", + set: Assignment_builder{ + Namespace: new("n"), Deployment: new("d"), Tenant: new("t"), + ScaleMaxInstances: proto.Uint32(1), + ScaleDownscaleStabilization: durationpb.New(2 * time.Minute), + }.Build(), + resolve: func(a *Assignment) time.Duration { return a.ScaleDownscaleStabilization(cluster) }, + wantUnset: cluster, + wantSet: 2 * time.Minute, + }, + { + name: "ScaleInitialReadinessDelay", + set: Assignment_builder{ + Namespace: new("n"), Deployment: new("d"), Tenant: new("t"), + ScaleMaxInstances: proto.Uint32(1), + ScaleInitialReadinessDelay: durationpb.New(15 * time.Second), + }.Build(), + resolve: func(a *Assignment) time.Duration { return a.ScaleInitialReadinessDelay(cluster) }, + wantUnset: cluster, + wantSet: 15 * time.Second, + }, + { + name: "RetryMinBackoff", + set: Assignment_builder{ + Namespace: new("n"), Deployment: new("d"), Tenant: new("t"), + ScaleMaxInstances: proto.Uint32(1), + RetryMinBackoff: durationpb.New(200 * time.Millisecond), + }.Build(), + resolve: func(a *Assignment) time.Duration { return a.RetryMinBackoff(cluster) }, + wantUnset: cluster, + wantSet: 200 * time.Millisecond, + }, + { + name: "RetryMaxBackoff", + set: Assignment_builder{ + Namespace: new("n"), Deployment: new("d"), Tenant: new("t"), + ScaleMaxInstances: proto.Uint32(1), + RetryMaxBackoff: durationpb.New(10 * time.Second), + }.Build(), + resolve: func(a *Assignment) time.Duration { return a.RetryMaxBackoff(cluster) }, + wantUnset: cluster, + wantSet: 10 * time.Second, + }, + { + name: "HeartbeatInterval (placeholder)", + set: Assignment_builder{ + Namespace: new("n"), Deployment: new("d"), Tenant: new("t"), + ScaleMaxInstances: proto.Uint32(1), + HeartbeatInterval: durationpb.New(7 * time.Second), + }.Build(), + resolve: func(a *Assignment) time.Duration { return a.HeartbeatInterval(cluster) }, + wantUnset: cluster, + wantSet: 7 * time.Second, + }, + { + name: "TransportDialTimeout (placeholder)", + set: Assignment_builder{ + Namespace: new("n"), Deployment: new("d"), Tenant: new("t"), + ScaleMaxInstances: proto.Uint32(1), + TransportDialTimeout: durationpb.New(500 * time.Millisecond), + }.Build(), + resolve: func(a *Assignment) time.Duration { return a.TransportDialTimeout(cluster) }, + wantUnset: cluster, + wantSet: 500 * time.Millisecond, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + empty := Assignment_builder{ + Namespace: new("n"), Deployment: new("d"), Tenant: new("t"), + ScaleMaxInstances: proto.Uint32(1), + }.Build() + assert.Equal(t, tc.resolve(empty), tc.wantUnset, "fallback to cluster default") + assert.Equal(t, tc.resolve(tc.set), tc.wantSet, "tenant override") + }) + } +} + +func TestRetryMaxAttemptsResolver(t *testing.T) { + t.Parallel() + + cluster := 6 + + empty := Assignment_builder{ + Namespace: new("n"), Deployment: new("d"), Tenant: new("t"), + ScaleMaxInstances: proto.Uint32(1), + }.Build() + override := Assignment_builder{ + Namespace: new("n"), Deployment: new("d"), Tenant: new("t"), + ScaleMaxInstances: proto.Uint32(1), + RetryMaxAttempts: proto.Uint32(3), + }.Build() + + assert.Equal(t, empty.RetryMaxAttempts(cluster), cluster, "fallback to cluster default") + assert.Equal(t, override.RetryMaxAttempts(cluster), 3, "tenant override") +} + +func TestScaleToleranceResolver(t *testing.T) { + t.Parallel() + + cluster := 0.1 + + empty := Assignment_builder{ + Namespace: new("n"), Deployment: new("d"), Tenant: new("t"), + ScaleMaxInstances: proto.Uint32(1), + }.Build() + override := Assignment_builder{ + Namespace: new("n"), Deployment: new("d"), Tenant: new("t"), + ScaleMaxInstances: proto.Uint32(1), + ScaleTolerance: new(0.25), + }.Build() + + assert.Equal(t, empty.ScaleTolerance(cluster), cluster, "fallback to cluster default") + assert.Equal(t, override.ScaleTolerance(cluster), 0.25, "tenant override") +} + +func TestPlaceholderEnumResolvers(t *testing.T) { + t.Parallel() + + // Default (unset) — should return the proto3 default (zero enum value). + a := Assignment_builder{ + Namespace: new("n"), Deployment: new("d"), Tenant: new("t"), + ScaleMaxInstances: proto.Uint32(1), + }.Build() + assert.Equal(t, a.ZoneSpread(), ZoneSpread_ZONE_SPREAD_UNSPECIFIED) + assert.Equal(t, a.ZoneAffinity(), ZoneAffinity_ZONE_AFFINITY_UNSPECIFIED) + assert.Equal(t, a.RetryBackpressure(), Backpressure_BACKPRESSURE_UNSPECIFIED) + + // Explicit override. + override := Assignment_builder{ + Namespace: new("n"), Deployment: new("d"), Tenant: new("t"), + ScaleMaxInstances: proto.Uint32(1), + ZoneSpread: ZoneSpread_ZONE_SPREAD_PREFERRED.Enum(), + ZoneAffinity: ZoneAffinity_ZONE_AFFINITY_REQUIRED.Enum(), + RetryBackpressure: Backpressure_BACKPRESSURE_RETRY.Enum(), + }.Build() + assert.Equal(t, override.ZoneSpread(), ZoneSpread_ZONE_SPREAD_PREFERRED) + assert.Equal(t, override.ZoneAffinity(), ZoneAffinity_ZONE_AFFINITY_REQUIRED) + assert.Equal(t, override.RetryBackpressure(), Backpressure_BACKPRESSURE_RETRY) +} + +func TestValidatePolicyFields(t *testing.T) { + t.Parallel() + + base := func(modify func(b *Assignment_builder)) *Assignment { + b := Assignment_builder{ + Namespace: new("n"), + Deployment: new("d"), + Tenant: new("t"), + ScaleMaxInstances: proto.Uint32(1), + } + modify(&b) + return b.Build() + } + + tests := []struct { + name string + fn *Assignment + wantErr string + }{ + {name: "flat-only valid", fn: base(func(b *Assignment_builder) {}), wantErr: ""}, + {name: "nested-only valid", fn: Assignment_builder{ + Namespace: new("n"), Deployment: new("d"), Tenant: new("t"), + Scale: Scale_builder{MaxInstances: proto.Uint32(1)}.Build(), + }.Build(), wantErr: ""}, + { + name: "hybrid min flat / max nested - invalid bounds", + fn: Assignment_builder{ + Namespace: new("n"), Deployment: new("d"), Tenant: new("t"), + Scale: Scale_builder{MaxInstances: proto.Uint32(3)}.Build(), + ScaleMinInstances: proto.Uint32(5), + }.Build(), + wantErr: "scale.min_instances (5) must be <= scale.max_instances (3)", + }, + { + name: "no scale at all", + fn: Assignment_builder{Namespace: new("n"), Deployment: new("d"), Tenant: new("t")}.Build(), + wantErr: "missing scale", + }, + {name: "negative scale_tolerance", fn: base(func(b *Assignment_builder) { + b.ScaleTolerance = new(-0.1) + }), wantErr: "scale_tolerance"}, + {name: "negative heartbeat_timeout", fn: base(func(b *Assignment_builder) { + b.HeartbeatTimeout = durationpb.New(-1 * time.Second) + }), wantErr: "heartbeat_timeout"}, + {name: "zero heartbeat_timeout", fn: base(func(b *Assignment_builder) { + b.HeartbeatTimeout = durationpb.New(0) + }), wantErr: "heartbeat_timeout must be > 0"}, + {name: "zero assign_timeout", fn: base(func(b *Assignment_builder) { + b.AssignTimeout = durationpb.New(0) + }), wantErr: "assign_timeout must be > 0"}, + {name: "zero assign_token_ttl", fn: base(func(b *Assignment_builder) { + b.AssignTokenTtl = durationpb.New(0) + }), wantErr: "assign_token_ttl must be > 0"}, + {name: "zero retry_max_attempts", fn: base(func(b *Assignment_builder) { + b.RetryMaxAttempts = proto.Uint32(0) + }), wantErr: "retry_max_attempts must be > 0"}, + {name: "negative retry_min_backoff", fn: base(func(b *Assignment_builder) { + b.RetryMinBackoff = durationpb.New(-1 * time.Millisecond) + }), wantErr: "retry_min_backoff"}, + {name: "inverted retry backoff bounds", fn: base(func(b *Assignment_builder) { + b.RetryMinBackoff = durationpb.New(5 * time.Second) + b.RetryMaxBackoff = durationpb.New(1 * time.Second) + }), wantErr: "retry_min_backoff (5s) must be <= retry_max_backoff (1s)"}, + {name: "negative scale_downscale_stabilization", fn: base(func(b *Assignment_builder) { + b.ScaleDownscaleStabilization = durationpb.New(-1 * time.Second) + }), wantErr: "scale_downscale_stabilization"}, + {name: "negative scale_initial_readiness_delay", fn: base(func(b *Assignment_builder) { + b.ScaleInitialReadinessDelay = durationpb.New(-1 * time.Second) + }), wantErr: "scale_initial_readiness_delay"}, + {name: "negative transport_dial_timeout", fn: base(func(b *Assignment_builder) { + b.TransportDialTimeout = durationpb.New(-1 * time.Millisecond) + }), wantErr: "transport_dial_timeout"}, + {name: "negative heartbeat_interval (placeholder)", fn: base(func(b *Assignment_builder) { + b.HeartbeatInterval = durationpb.New(-1 * time.Second) + }), wantErr: "heartbeat_interval"}, + // Placeholder enum: UNSPECIFIED is valid (placeholder-knob policy: + // Validate is wired-state-agnostic; followups treat UNSPECIFIED as + // fall back to cluster default). + {name: "zone_spread unspecified accepted", fn: base(func(b *Assignment_builder) { + b.ZoneSpread = ZoneSpread_ZONE_SPREAD_UNSPECIFIED.Enum() + }), wantErr: ""}, + {name: "zone_spread explicit value accepted", fn: base(func(b *Assignment_builder) { + b.ZoneSpread = ZoneSpread_ZONE_SPREAD_REQUIRED.Enum() + }), wantErr: ""}, + // Well-formed placeholder values pass. + {name: "transport_dial_timeout 100ms accepted", fn: base(func(b *Assignment_builder) { + b.TransportDialTimeout = durationpb.New(100 * time.Millisecond) + }), wantErr: ""}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + err := tc.fn.Validate() + if tc.wantErr == "" { + assert.NilError(t, err) + } else { + assert.ErrorContains(t, err, tc.wantErr) + } + }) + } +} diff --git a/internal/skipper/types.pb.go b/internal/skipper/types.pb.go index 28935dbd..ea16cb3d 100644 --- a/internal/skipper/types.pb.go +++ b/internal/skipper/types.pb.go @@ -10,6 +10,7 @@ import ( protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" _ "google.golang.org/protobuf/types/gofeaturespb" + durationpb "google.golang.org/protobuf/types/known/durationpb" timestamppb "google.golang.org/protobuf/types/known/timestamppb" reflect "reflect" unsafe "unsafe" @@ -22,6 +23,156 @@ const ( _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) +// ZoneSpread controls how the controller spreads instances across availability +// zones for an assignment. Placeholder (followup: zone-aware-placement); no +// decision site reads it in the current release. +type ZoneSpread int32 + +const ( + ZoneSpread_ZONE_SPREAD_UNSPECIFIED ZoneSpread = 0 + ZoneSpread_ZONE_SPREAD_NONE ZoneSpread = 1 + ZoneSpread_ZONE_SPREAD_PREFERRED ZoneSpread = 2 + ZoneSpread_ZONE_SPREAD_REQUIRED ZoneSpread = 3 +) + +// Enum value maps for ZoneSpread. +var ( + ZoneSpread_name = map[int32]string{ + 0: "ZONE_SPREAD_UNSPECIFIED", + 1: "ZONE_SPREAD_NONE", + 2: "ZONE_SPREAD_PREFERRED", + 3: "ZONE_SPREAD_REQUIRED", + } + ZoneSpread_value = map[string]int32{ + "ZONE_SPREAD_UNSPECIFIED": 0, + "ZONE_SPREAD_NONE": 1, + "ZONE_SPREAD_PREFERRED": 2, + "ZONE_SPREAD_REQUIRED": 3, + } +) + +func (x ZoneSpread) Enum() *ZoneSpread { + p := new(ZoneSpread) + *p = x + return p +} + +func (x ZoneSpread) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (ZoneSpread) Descriptor() protoreflect.EnumDescriptor { + return file_types_proto_enumTypes[0].Descriptor() +} + +func (ZoneSpread) Type() protoreflect.EnumType { + return &file_types_proto_enumTypes[0] +} + +func (x ZoneSpread) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// ZoneAffinity controls how the router prefers same-zone instances when routing +// requests for an assignment. Placeholder (followup: zone-aware-affinity); no +// decision site reads it in the current release. +type ZoneAffinity int32 + +const ( + ZoneAffinity_ZONE_AFFINITY_UNSPECIFIED ZoneAffinity = 0 + ZoneAffinity_ZONE_AFFINITY_NONE ZoneAffinity = 1 + ZoneAffinity_ZONE_AFFINITY_PREFERRED ZoneAffinity = 2 + ZoneAffinity_ZONE_AFFINITY_REQUIRED ZoneAffinity = 3 +) + +// Enum value maps for ZoneAffinity. +var ( + ZoneAffinity_name = map[int32]string{ + 0: "ZONE_AFFINITY_UNSPECIFIED", + 1: "ZONE_AFFINITY_NONE", + 2: "ZONE_AFFINITY_PREFERRED", + 3: "ZONE_AFFINITY_REQUIRED", + } + ZoneAffinity_value = map[string]int32{ + "ZONE_AFFINITY_UNSPECIFIED": 0, + "ZONE_AFFINITY_NONE": 1, + "ZONE_AFFINITY_PREFERRED": 2, + "ZONE_AFFINITY_REQUIRED": 3, + } +) + +func (x ZoneAffinity) Enum() *ZoneAffinity { + p := new(ZoneAffinity) + *p = x + return p +} + +func (x ZoneAffinity) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (ZoneAffinity) Descriptor() protoreflect.EnumDescriptor { + return file_types_proto_enumTypes[1].Descriptor() +} + +func (ZoneAffinity) Type() protoreflect.EnumType { + return &file_types_proto_enumTypes[1] +} + +func (x ZoneAffinity) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Backpressure controls how the router responds when an upstream instance +// signals backpressure on a request. Placeholder (followup: backpressure); no +// decision site reads it in the current release. +type Backpressure int32 + +const ( + Backpressure_BACKPRESSURE_UNSPECIFIED Backpressure = 0 + Backpressure_BACKPRESSURE_IGNORE Backpressure = 1 + Backpressure_BACKPRESSURE_RETRY Backpressure = 2 + Backpressure_BACKPRESSURE_RETRY_AND_EJECT Backpressure = 3 +) + +// Enum value maps for Backpressure. +var ( + Backpressure_name = map[int32]string{ + 0: "BACKPRESSURE_UNSPECIFIED", + 1: "BACKPRESSURE_IGNORE", + 2: "BACKPRESSURE_RETRY", + 3: "BACKPRESSURE_RETRY_AND_EJECT", + } + Backpressure_value = map[string]int32{ + "BACKPRESSURE_UNSPECIFIED": 0, + "BACKPRESSURE_IGNORE": 1, + "BACKPRESSURE_RETRY": 2, + "BACKPRESSURE_RETRY_AND_EJECT": 3, + } +) + +func (x Backpressure) Enum() *Backpressure { + p := new(Backpressure) + *p = x + return p +} + +func (x Backpressure) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (Backpressure) Descriptor() protoreflect.EnumDescriptor { + return file_types_proto_enumTypes[2].Descriptor() +} + +func (Backpressure) Type() protoreflect.EnumType { + return &file_types_proto_enumTypes[2] +} + +func (x Backpressure) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + type ScaleReason int32 const ( @@ -64,11 +215,11 @@ func (x ScaleReason) String() string { } func (ScaleReason) Descriptor() protoreflect.EnumDescriptor { - return file_types_proto_enumTypes[0].Descriptor() + return file_types_proto_enumTypes[3].Descriptor() } func (ScaleReason) Type() protoreflect.EnumType { - return &file_types_proto_enumTypes[0] + return &file_types_proto_enumTypes[3] } func (x ScaleReason) Number() protoreflect.EnumNumber { @@ -123,11 +274,11 @@ func (x EventType) String() string { } func (EventType) Descriptor() protoreflect.EnumDescriptor { - return file_types_proto_enumTypes[1].Descriptor() + return file_types_proto_enumTypes[4].Descriptor() } func (EventType) Type() protoreflect.EnumType { - return &file_types_proto_enumTypes[1] + return &file_types_proto_enumTypes[4] } func (x EventType) Number() protoreflect.EnumNumber { @@ -167,11 +318,11 @@ func (x EventSeverity) String() string { } func (EventSeverity) Descriptor() protoreflect.EnumDescriptor { - return file_types_proto_enumTypes[2].Descriptor() + return file_types_proto_enumTypes[5].Descriptor() } func (EventSeverity) Type() protoreflect.EnumType { - return &file_types_proto_enumTypes[2] + return &file_types_proto_enumTypes[5] } func (x EventSeverity) Number() protoreflect.EnumNumber { @@ -373,18 +524,58 @@ func (b0 Scale_builder) Build() *Scale { return m0 } +// Assignment carries the per-tenant identity and policy for a deployment. +// Field numbers are grouped by concern, reserving ranges for future growth: +// +// 1-9 identity +// 10-29 scale (excluding scale = 5, grandfathered) +// 30-39 zone +// 40-49 assign +// 50-59 heartbeat +// 60-79 retry +// 80-109 transport +// 110-199 future type Assignment struct { - state protoimpl.MessageState `protogen:"opaque.v1"` - xxx_hidden_Namespace *string `protobuf:"bytes,1,opt,name=namespace"` - xxx_hidden_Deployment *string `protobuf:"bytes,2,opt,name=deployment"` - xxx_hidden_Tenant *string `protobuf:"bytes,3,opt,name=tenant"` - xxx_hidden_Metadata *string `protobuf:"bytes,4,opt,name=metadata"` - xxx_hidden_Scale *Scale `protobuf:"bytes,5,opt,name=scale"` - xxx_hidden_Oneshot bool `protobuf:"varint,6,opt,name=oneshot"` - XXX_raceDetectHookData protoimpl.RaceDetectHookData - XXX_presence [1]uint32 - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"opaque.v1"` + xxx_hidden_Namespace *string `protobuf:"bytes,1,opt,name=namespace"` + xxx_hidden_Deployment *string `protobuf:"bytes,2,opt,name=deployment"` + xxx_hidden_Tenant *string `protobuf:"bytes,3,opt,name=tenant"` + xxx_hidden_Metadata *string `protobuf:"bytes,4,opt,name=metadata"` + xxx_hidden_Scale *Scale `protobuf:"bytes,5,opt,name=scale"` + xxx_hidden_Oneshot bool `protobuf:"varint,6,opt,name=oneshot"` + xxx_hidden_ScaleMinInstances uint32 `protobuf:"varint,10,opt,name=scale_min_instances,json=scaleMinInstances"` + xxx_hidden_ScaleMaxInstances uint32 `protobuf:"varint,11,opt,name=scale_max_instances,json=scaleMaxInstances"` + xxx_hidden_ScaleTargetCpuMillicores uint32 `protobuf:"varint,12,opt,name=scale_target_cpu_millicores,json=scaleTargetCpuMillicores"` + xxx_hidden_ScaleTargetMemoryMebibytes uint32 `protobuf:"varint,13,opt,name=scale_target_memory_mebibytes,json=scaleTargetMemoryMebibytes"` + xxx_hidden_ScaleTargetInFlightRequests uint32 `protobuf:"varint,14,opt,name=scale_target_in_flight_requests,json=scaleTargetInFlightRequests"` + xxx_hidden_ScaleTolerance float64 `protobuf:"fixed64,15,opt,name=scale_tolerance,json=scaleTolerance"` + xxx_hidden_ScaleDownscaleStabilization *durationpb.Duration `protobuf:"bytes,16,opt,name=scale_downscale_stabilization,json=scaleDownscaleStabilization"` + xxx_hidden_ScaleInitialReadinessDelay *durationpb.Duration `protobuf:"bytes,17,opt,name=scale_initial_readiness_delay,json=scaleInitialReadinessDelay"` + xxx_hidden_ZoneSpread ZoneSpread `protobuf:"varint,30,opt,name=zone_spread,json=zoneSpread,enum=skipper.ZoneSpread"` + xxx_hidden_ZoneMin uint32 `protobuf:"varint,31,opt,name=zone_min,json=zoneMin"` + xxx_hidden_ZoneAffinity ZoneAffinity `protobuf:"varint,32,opt,name=zone_affinity,json=zoneAffinity,enum=skipper.ZoneAffinity"` + xxx_hidden_AssignPath *string `protobuf:"bytes,40,opt,name=assign_path,json=assignPath"` + xxx_hidden_AssignTimeout *durationpb.Duration `protobuf:"bytes,41,opt,name=assign_timeout,json=assignTimeout"` + xxx_hidden_AssignTokenTtl *durationpb.Duration `protobuf:"bytes,42,opt,name=assign_token_ttl,json=assignTokenTtl"` + xxx_hidden_HeartbeatInterval *durationpb.Duration `protobuf:"bytes,50,opt,name=heartbeat_interval,json=heartbeatInterval"` + xxx_hidden_HeartbeatTimeout *durationpb.Duration `protobuf:"bytes,51,opt,name=heartbeat_timeout,json=heartbeatTimeout"` + xxx_hidden_RetryMaxAttempts uint32 `protobuf:"varint,60,opt,name=retry_max_attempts,json=retryMaxAttempts"` + xxx_hidden_RetryMinBackoff *durationpb.Duration `protobuf:"bytes,61,opt,name=retry_min_backoff,json=retryMinBackoff"` + xxx_hidden_RetryMaxBackoff *durationpb.Duration `protobuf:"bytes,62,opt,name=retry_max_backoff,json=retryMaxBackoff"` + xxx_hidden_RetryBackpressure Backpressure `protobuf:"varint,63,opt,name=retry_backpressure,json=retryBackpressure,enum=skipper.Backpressure"` + xxx_hidden_RetryStatusCodes []uint32 `protobuf:"varint,64,rep,packed,name=retry_status_codes,json=retryStatusCodes"` + xxx_hidden_TransportDialTimeout *durationpb.Duration `protobuf:"bytes,80,opt,name=transport_dial_timeout,json=transportDialTimeout"` + xxx_hidden_TransportKeepalive *durationpb.Duration `protobuf:"bytes,81,opt,name=transport_keepalive,json=transportKeepalive"` + xxx_hidden_TransportIdleConnTimeout *durationpb.Duration `protobuf:"bytes,82,opt,name=transport_idle_conn_timeout,json=transportIdleConnTimeout"` + xxx_hidden_TransportTlsHandshakeTimeout *durationpb.Duration `protobuf:"bytes,83,opt,name=transport_tls_handshake_timeout,json=transportTlsHandshakeTimeout"` + xxx_hidden_TransportMaxIdleConns uint32 `protobuf:"varint,84,opt,name=transport_max_idle_conns,json=transportMaxIdleConns"` + xxx_hidden_TransportForceHttp2 bool `protobuf:"varint,85,opt,name=transport_force_http2,json=transportForceHttp2"` + xxx_hidden_TransportDisableCompression bool `protobuf:"varint,86,opt,name=transport_disable_compression,json=transportDisableCompression"` + xxx_hidden_TransportFlushInterval *durationpb.Duration `protobuf:"bytes,87,opt,name=transport_flush_interval,json=transportFlushInterval"` + XXX_raceDetectHookData protoimpl.RaceDetectHookData + XXX_presence [2]uint32 + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *Assignment) Reset() { @@ -466,24 +657,236 @@ func (x *Assignment) GetOneshot() bool { return false } +func (x *Assignment) GetScaleMinInstances() uint32 { + if x != nil { + return x.xxx_hidden_ScaleMinInstances + } + return 0 +} + +func (x *Assignment) GetScaleMaxInstances() uint32 { + if x != nil { + return x.xxx_hidden_ScaleMaxInstances + } + return 0 +} + +func (x *Assignment) GetScaleTargetCpuMillicores() uint32 { + if x != nil { + return x.xxx_hidden_ScaleTargetCpuMillicores + } + return 0 +} + +func (x *Assignment) GetScaleTargetMemoryMebibytes() uint32 { + if x != nil { + return x.xxx_hidden_ScaleTargetMemoryMebibytes + } + return 0 +} + +func (x *Assignment) GetScaleTargetInFlightRequests() uint32 { + if x != nil { + return x.xxx_hidden_ScaleTargetInFlightRequests + } + return 0 +} + +func (x *Assignment) GetScaleTolerance() float64 { + if x != nil { + return x.xxx_hidden_ScaleTolerance + } + return 0 +} + +func (x *Assignment) GetScaleDownscaleStabilization() *durationpb.Duration { + if x != nil { + return x.xxx_hidden_ScaleDownscaleStabilization + } + return nil +} + +func (x *Assignment) GetScaleInitialReadinessDelay() *durationpb.Duration { + if x != nil { + return x.xxx_hidden_ScaleInitialReadinessDelay + } + return nil +} + +func (x *Assignment) GetZoneSpread() ZoneSpread { + if x != nil { + if protoimpl.X.Present(&(x.XXX_presence[0]), 14) { + return x.xxx_hidden_ZoneSpread + } + } + return ZoneSpread_ZONE_SPREAD_UNSPECIFIED +} + +func (x *Assignment) GetZoneMin() uint32 { + if x != nil { + return x.xxx_hidden_ZoneMin + } + return 0 +} + +func (x *Assignment) GetZoneAffinity() ZoneAffinity { + if x != nil { + if protoimpl.X.Present(&(x.XXX_presence[0]), 16) { + return x.xxx_hidden_ZoneAffinity + } + } + return ZoneAffinity_ZONE_AFFINITY_UNSPECIFIED +} + +func (x *Assignment) GetAssignPath() string { + if x != nil { + if x.xxx_hidden_AssignPath != nil { + return *x.xxx_hidden_AssignPath + } + return "" + } + return "" +} + +func (x *Assignment) GetAssignTimeout() *durationpb.Duration { + if x != nil { + return x.xxx_hidden_AssignTimeout + } + return nil +} + +func (x *Assignment) GetAssignTokenTtl() *durationpb.Duration { + if x != nil { + return x.xxx_hidden_AssignTokenTtl + } + return nil +} + +func (x *Assignment) GetHeartbeatInterval() *durationpb.Duration { + if x != nil { + return x.xxx_hidden_HeartbeatInterval + } + return nil +} + +func (x *Assignment) GetHeartbeatTimeout() *durationpb.Duration { + if x != nil { + return x.xxx_hidden_HeartbeatTimeout + } + return nil +} + +func (x *Assignment) GetRetryMaxAttempts() uint32 { + if x != nil { + return x.xxx_hidden_RetryMaxAttempts + } + return 0 +} + +func (x *Assignment) GetRetryMinBackoff() *durationpb.Duration { + if x != nil { + return x.xxx_hidden_RetryMinBackoff + } + return nil +} + +func (x *Assignment) GetRetryMaxBackoff() *durationpb.Duration { + if x != nil { + return x.xxx_hidden_RetryMaxBackoff + } + return nil +} + +func (x *Assignment) GetRetryBackpressure() Backpressure { + if x != nil { + if protoimpl.X.Present(&(x.XXX_presence[0]), 25) { + return x.xxx_hidden_RetryBackpressure + } + } + return Backpressure_BACKPRESSURE_UNSPECIFIED +} + +func (x *Assignment) GetRetryStatusCodes() []uint32 { + if x != nil { + return x.xxx_hidden_RetryStatusCodes + } + return nil +} + +func (x *Assignment) GetTransportDialTimeout() *durationpb.Duration { + if x != nil { + return x.xxx_hidden_TransportDialTimeout + } + return nil +} + +func (x *Assignment) GetTransportKeepalive() *durationpb.Duration { + if x != nil { + return x.xxx_hidden_TransportKeepalive + } + return nil +} + +func (x *Assignment) GetTransportIdleConnTimeout() *durationpb.Duration { + if x != nil { + return x.xxx_hidden_TransportIdleConnTimeout + } + return nil +} + +func (x *Assignment) GetTransportTlsHandshakeTimeout() *durationpb.Duration { + if x != nil { + return x.xxx_hidden_TransportTlsHandshakeTimeout + } + return nil +} + +func (x *Assignment) GetTransportMaxIdleConns() uint32 { + if x != nil { + return x.xxx_hidden_TransportMaxIdleConns + } + return 0 +} + +func (x *Assignment) GetTransportForceHttp2() bool { + if x != nil { + return x.xxx_hidden_TransportForceHttp2 + } + return false +} + +func (x *Assignment) GetTransportDisableCompression() bool { + if x != nil { + return x.xxx_hidden_TransportDisableCompression + } + return false +} + +func (x *Assignment) GetTransportFlushInterval() *durationpb.Duration { + if x != nil { + return x.xxx_hidden_TransportFlushInterval + } + return nil +} + func (x *Assignment) SetNamespace(v string) { x.xxx_hidden_Namespace = &v - protoimpl.X.SetPresent(&(x.XXX_presence[0]), 0, 6) + protoimpl.X.SetPresent(&(x.XXX_presence[0]), 0, 35) } func (x *Assignment) SetDeployment(v string) { x.xxx_hidden_Deployment = &v - protoimpl.X.SetPresent(&(x.XXX_presence[0]), 1, 6) + protoimpl.X.SetPresent(&(x.XXX_presence[0]), 1, 35) } func (x *Assignment) SetTenant(v string) { x.xxx_hidden_Tenant = &v - protoimpl.X.SetPresent(&(x.XXX_presence[0]), 2, 6) + protoimpl.X.SetPresent(&(x.XXX_presence[0]), 2, 35) } func (x *Assignment) SetMetadata(v string) { x.xxx_hidden_Metadata = &v - protoimpl.X.SetPresent(&(x.XXX_presence[0]), 3, 6) + protoimpl.X.SetPresent(&(x.XXX_presence[0]), 3, 35) } func (x *Assignment) SetScale(v *Scale) { @@ -492,7 +895,138 @@ func (x *Assignment) SetScale(v *Scale) { func (x *Assignment) SetOneshot(v bool) { x.xxx_hidden_Oneshot = v - protoimpl.X.SetPresent(&(x.XXX_presence[0]), 5, 6) + protoimpl.X.SetPresent(&(x.XXX_presence[0]), 5, 35) +} + +func (x *Assignment) SetScaleMinInstances(v uint32) { + x.xxx_hidden_ScaleMinInstances = v + protoimpl.X.SetPresent(&(x.XXX_presence[0]), 6, 35) +} + +func (x *Assignment) SetScaleMaxInstances(v uint32) { + x.xxx_hidden_ScaleMaxInstances = v + protoimpl.X.SetPresent(&(x.XXX_presence[0]), 7, 35) +} + +func (x *Assignment) SetScaleTargetCpuMillicores(v uint32) { + x.xxx_hidden_ScaleTargetCpuMillicores = v + protoimpl.X.SetPresent(&(x.XXX_presence[0]), 8, 35) +} + +func (x *Assignment) SetScaleTargetMemoryMebibytes(v uint32) { + x.xxx_hidden_ScaleTargetMemoryMebibytes = v + protoimpl.X.SetPresent(&(x.XXX_presence[0]), 9, 35) +} + +func (x *Assignment) SetScaleTargetInFlightRequests(v uint32) { + x.xxx_hidden_ScaleTargetInFlightRequests = v + protoimpl.X.SetPresent(&(x.XXX_presence[0]), 10, 35) +} + +func (x *Assignment) SetScaleTolerance(v float64) { + x.xxx_hidden_ScaleTolerance = v + protoimpl.X.SetPresent(&(x.XXX_presence[0]), 11, 35) +} + +func (x *Assignment) SetScaleDownscaleStabilization(v *durationpb.Duration) { + x.xxx_hidden_ScaleDownscaleStabilization = v +} + +func (x *Assignment) SetScaleInitialReadinessDelay(v *durationpb.Duration) { + x.xxx_hidden_ScaleInitialReadinessDelay = v +} + +func (x *Assignment) SetZoneSpread(v ZoneSpread) { + x.xxx_hidden_ZoneSpread = v + protoimpl.X.SetPresent(&(x.XXX_presence[0]), 14, 35) +} + +func (x *Assignment) SetZoneMin(v uint32) { + x.xxx_hidden_ZoneMin = v + protoimpl.X.SetPresent(&(x.XXX_presence[0]), 15, 35) +} + +func (x *Assignment) SetZoneAffinity(v ZoneAffinity) { + x.xxx_hidden_ZoneAffinity = v + protoimpl.X.SetPresent(&(x.XXX_presence[0]), 16, 35) +} + +func (x *Assignment) SetAssignPath(v string) { + x.xxx_hidden_AssignPath = &v + protoimpl.X.SetPresent(&(x.XXX_presence[0]), 17, 35) +} + +func (x *Assignment) SetAssignTimeout(v *durationpb.Duration) { + x.xxx_hidden_AssignTimeout = v +} + +func (x *Assignment) SetAssignTokenTtl(v *durationpb.Duration) { + x.xxx_hidden_AssignTokenTtl = v +} + +func (x *Assignment) SetHeartbeatInterval(v *durationpb.Duration) { + x.xxx_hidden_HeartbeatInterval = v +} + +func (x *Assignment) SetHeartbeatTimeout(v *durationpb.Duration) { + x.xxx_hidden_HeartbeatTimeout = v +} + +func (x *Assignment) SetRetryMaxAttempts(v uint32) { + x.xxx_hidden_RetryMaxAttempts = v + protoimpl.X.SetPresent(&(x.XXX_presence[0]), 22, 35) +} + +func (x *Assignment) SetRetryMinBackoff(v *durationpb.Duration) { + x.xxx_hidden_RetryMinBackoff = v +} + +func (x *Assignment) SetRetryMaxBackoff(v *durationpb.Duration) { + x.xxx_hidden_RetryMaxBackoff = v +} + +func (x *Assignment) SetRetryBackpressure(v Backpressure) { + x.xxx_hidden_RetryBackpressure = v + protoimpl.X.SetPresent(&(x.XXX_presence[0]), 25, 35) +} + +func (x *Assignment) SetRetryStatusCodes(v []uint32) { + x.xxx_hidden_RetryStatusCodes = v +} + +func (x *Assignment) SetTransportDialTimeout(v *durationpb.Duration) { + x.xxx_hidden_TransportDialTimeout = v +} + +func (x *Assignment) SetTransportKeepalive(v *durationpb.Duration) { + x.xxx_hidden_TransportKeepalive = v +} + +func (x *Assignment) SetTransportIdleConnTimeout(v *durationpb.Duration) { + x.xxx_hidden_TransportIdleConnTimeout = v +} + +func (x *Assignment) SetTransportTlsHandshakeTimeout(v *durationpb.Duration) { + x.xxx_hidden_TransportTlsHandshakeTimeout = v +} + +func (x *Assignment) SetTransportMaxIdleConns(v uint32) { + x.xxx_hidden_TransportMaxIdleConns = v + protoimpl.X.SetPresent(&(x.XXX_presence[0]), 31, 35) +} + +func (x *Assignment) SetTransportForceHttp2(v bool) { + x.xxx_hidden_TransportForceHttp2 = v + protoimpl.X.SetPresent(&(x.XXX_presence[1]), 32, 35) +} + +func (x *Assignment) SetTransportDisableCompression(v bool) { + x.xxx_hidden_TransportDisableCompression = v + protoimpl.X.SetPresent(&(x.XXX_presence[1]), 33, 35) +} + +func (x *Assignment) SetTransportFlushInterval(v *durationpb.Duration) { + x.xxx_hidden_TransportFlushInterval = v } func (x *Assignment) HasNamespace() bool { @@ -537,6 +1071,202 @@ func (x *Assignment) HasOneshot() bool { return protoimpl.X.Present(&(x.XXX_presence[0]), 5) } +func (x *Assignment) HasScaleMinInstances() bool { + if x == nil { + return false + } + return protoimpl.X.Present(&(x.XXX_presence[0]), 6) +} + +func (x *Assignment) HasScaleMaxInstances() bool { + if x == nil { + return false + } + return protoimpl.X.Present(&(x.XXX_presence[0]), 7) +} + +func (x *Assignment) HasScaleTargetCpuMillicores() bool { + if x == nil { + return false + } + return protoimpl.X.Present(&(x.XXX_presence[0]), 8) +} + +func (x *Assignment) HasScaleTargetMemoryMebibytes() bool { + if x == nil { + return false + } + return protoimpl.X.Present(&(x.XXX_presence[0]), 9) +} + +func (x *Assignment) HasScaleTargetInFlightRequests() bool { + if x == nil { + return false + } + return protoimpl.X.Present(&(x.XXX_presence[0]), 10) +} + +func (x *Assignment) HasScaleTolerance() bool { + if x == nil { + return false + } + return protoimpl.X.Present(&(x.XXX_presence[0]), 11) +} + +func (x *Assignment) HasScaleDownscaleStabilization() bool { + if x == nil { + return false + } + return x.xxx_hidden_ScaleDownscaleStabilization != nil +} + +func (x *Assignment) HasScaleInitialReadinessDelay() bool { + if x == nil { + return false + } + return x.xxx_hidden_ScaleInitialReadinessDelay != nil +} + +func (x *Assignment) HasZoneSpread() bool { + if x == nil { + return false + } + return protoimpl.X.Present(&(x.XXX_presence[0]), 14) +} + +func (x *Assignment) HasZoneMin() bool { + if x == nil { + return false + } + return protoimpl.X.Present(&(x.XXX_presence[0]), 15) +} + +func (x *Assignment) HasZoneAffinity() bool { + if x == nil { + return false + } + return protoimpl.X.Present(&(x.XXX_presence[0]), 16) +} + +func (x *Assignment) HasAssignPath() bool { + if x == nil { + return false + } + return protoimpl.X.Present(&(x.XXX_presence[0]), 17) +} + +func (x *Assignment) HasAssignTimeout() bool { + if x == nil { + return false + } + return x.xxx_hidden_AssignTimeout != nil +} + +func (x *Assignment) HasAssignTokenTtl() bool { + if x == nil { + return false + } + return x.xxx_hidden_AssignTokenTtl != nil +} + +func (x *Assignment) HasHeartbeatInterval() bool { + if x == nil { + return false + } + return x.xxx_hidden_HeartbeatInterval != nil +} + +func (x *Assignment) HasHeartbeatTimeout() bool { + if x == nil { + return false + } + return x.xxx_hidden_HeartbeatTimeout != nil +} + +func (x *Assignment) HasRetryMaxAttempts() bool { + if x == nil { + return false + } + return protoimpl.X.Present(&(x.XXX_presence[0]), 22) +} + +func (x *Assignment) HasRetryMinBackoff() bool { + if x == nil { + return false + } + return x.xxx_hidden_RetryMinBackoff != nil +} + +func (x *Assignment) HasRetryMaxBackoff() bool { + if x == nil { + return false + } + return x.xxx_hidden_RetryMaxBackoff != nil +} + +func (x *Assignment) HasRetryBackpressure() bool { + if x == nil { + return false + } + return protoimpl.X.Present(&(x.XXX_presence[0]), 25) +} + +func (x *Assignment) HasTransportDialTimeout() bool { + if x == nil { + return false + } + return x.xxx_hidden_TransportDialTimeout != nil +} + +func (x *Assignment) HasTransportKeepalive() bool { + if x == nil { + return false + } + return x.xxx_hidden_TransportKeepalive != nil +} + +func (x *Assignment) HasTransportIdleConnTimeout() bool { + if x == nil { + return false + } + return x.xxx_hidden_TransportIdleConnTimeout != nil +} + +func (x *Assignment) HasTransportTlsHandshakeTimeout() bool { + if x == nil { + return false + } + return x.xxx_hidden_TransportTlsHandshakeTimeout != nil +} + +func (x *Assignment) HasTransportMaxIdleConns() bool { + if x == nil { + return false + } + return protoimpl.X.Present(&(x.XXX_presence[0]), 31) +} + +func (x *Assignment) HasTransportForceHttp2() bool { + if x == nil { + return false + } + return protoimpl.X.Present(&(x.XXX_presence[1]), 32) +} + +func (x *Assignment) HasTransportDisableCompression() bool { + if x == nil { + return false + } + return protoimpl.X.Present(&(x.XXX_presence[1]), 33) +} + +func (x *Assignment) HasTransportFlushInterval() bool { + if x == nil { + return false + } + return x.xxx_hidden_TransportFlushInterval != nil +} + func (x *Assignment) ClearNamespace() { protoimpl.X.ClearPresent(&(x.XXX_presence[0]), 0) x.xxx_hidden_Namespace = nil @@ -566,15 +1296,181 @@ func (x *Assignment) ClearOneshot() { x.xxx_hidden_Oneshot = false } +func (x *Assignment) ClearScaleMinInstances() { + protoimpl.X.ClearPresent(&(x.XXX_presence[0]), 6) + x.xxx_hidden_ScaleMinInstances = 0 +} + +func (x *Assignment) ClearScaleMaxInstances() { + protoimpl.X.ClearPresent(&(x.XXX_presence[0]), 7) + x.xxx_hidden_ScaleMaxInstances = 0 +} + +func (x *Assignment) ClearScaleTargetCpuMillicores() { + protoimpl.X.ClearPresent(&(x.XXX_presence[0]), 8) + x.xxx_hidden_ScaleTargetCpuMillicores = 0 +} + +func (x *Assignment) ClearScaleTargetMemoryMebibytes() { + protoimpl.X.ClearPresent(&(x.XXX_presence[0]), 9) + x.xxx_hidden_ScaleTargetMemoryMebibytes = 0 +} + +func (x *Assignment) ClearScaleTargetInFlightRequests() { + protoimpl.X.ClearPresent(&(x.XXX_presence[0]), 10) + x.xxx_hidden_ScaleTargetInFlightRequests = 0 +} + +func (x *Assignment) ClearScaleTolerance() { + protoimpl.X.ClearPresent(&(x.XXX_presence[0]), 11) + x.xxx_hidden_ScaleTolerance = 0 +} + +func (x *Assignment) ClearScaleDownscaleStabilization() { + x.xxx_hidden_ScaleDownscaleStabilization = nil +} + +func (x *Assignment) ClearScaleInitialReadinessDelay() { + x.xxx_hidden_ScaleInitialReadinessDelay = nil +} + +func (x *Assignment) ClearZoneSpread() { + protoimpl.X.ClearPresent(&(x.XXX_presence[0]), 14) + x.xxx_hidden_ZoneSpread = ZoneSpread_ZONE_SPREAD_UNSPECIFIED +} + +func (x *Assignment) ClearZoneMin() { + protoimpl.X.ClearPresent(&(x.XXX_presence[0]), 15) + x.xxx_hidden_ZoneMin = 0 +} + +func (x *Assignment) ClearZoneAffinity() { + protoimpl.X.ClearPresent(&(x.XXX_presence[0]), 16) + x.xxx_hidden_ZoneAffinity = ZoneAffinity_ZONE_AFFINITY_UNSPECIFIED +} + +func (x *Assignment) ClearAssignPath() { + protoimpl.X.ClearPresent(&(x.XXX_presence[0]), 17) + x.xxx_hidden_AssignPath = nil +} + +func (x *Assignment) ClearAssignTimeout() { + x.xxx_hidden_AssignTimeout = nil +} + +func (x *Assignment) ClearAssignTokenTtl() { + x.xxx_hidden_AssignTokenTtl = nil +} + +func (x *Assignment) ClearHeartbeatInterval() { + x.xxx_hidden_HeartbeatInterval = nil +} + +func (x *Assignment) ClearHeartbeatTimeout() { + x.xxx_hidden_HeartbeatTimeout = nil +} + +func (x *Assignment) ClearRetryMaxAttempts() { + protoimpl.X.ClearPresent(&(x.XXX_presence[0]), 22) + x.xxx_hidden_RetryMaxAttempts = 0 +} + +func (x *Assignment) ClearRetryMinBackoff() { + x.xxx_hidden_RetryMinBackoff = nil +} + +func (x *Assignment) ClearRetryMaxBackoff() { + x.xxx_hidden_RetryMaxBackoff = nil +} + +func (x *Assignment) ClearRetryBackpressure() { + protoimpl.X.ClearPresent(&(x.XXX_presence[0]), 25) + x.xxx_hidden_RetryBackpressure = Backpressure_BACKPRESSURE_UNSPECIFIED +} + +func (x *Assignment) ClearTransportDialTimeout() { + x.xxx_hidden_TransportDialTimeout = nil +} + +func (x *Assignment) ClearTransportKeepalive() { + x.xxx_hidden_TransportKeepalive = nil +} + +func (x *Assignment) ClearTransportIdleConnTimeout() { + x.xxx_hidden_TransportIdleConnTimeout = nil +} + +func (x *Assignment) ClearTransportTlsHandshakeTimeout() { + x.xxx_hidden_TransportTlsHandshakeTimeout = nil +} + +func (x *Assignment) ClearTransportMaxIdleConns() { + protoimpl.X.ClearPresent(&(x.XXX_presence[0]), 31) + x.xxx_hidden_TransportMaxIdleConns = 0 +} + +func (x *Assignment) ClearTransportForceHttp2() { + protoimpl.X.ClearPresent(&(x.XXX_presence[1]), 32) + x.xxx_hidden_TransportForceHttp2 = false +} + +func (x *Assignment) ClearTransportDisableCompression() { + protoimpl.X.ClearPresent(&(x.XXX_presence[1]), 33) + x.xxx_hidden_TransportDisableCompression = false +} + +func (x *Assignment) ClearTransportFlushInterval() { + x.xxx_hidden_TransportFlushInterval = nil +} + type Assignment_builder struct { _ [0]func() // Prevents comparability and use of unkeyed literals for the builder. + // Identity (1-9). Namespace *string Deployment *string Tenant *string Metadata *string - Scale *Scale - Oneshot *bool + // reserved: scale = 5 (grandfathered, predates flat layout) + Scale *Scale + Oneshot *bool + // Scale policy (10-29). The first five fields are flat aliases for the + // long-shipped Scale sub-message; resolvers prefer flat with fallback to + // Scale. The remaining three are flat-only additions. + ScaleMinInstances *uint32 + ScaleMaxInstances *uint32 + ScaleTargetCpuMillicores *uint32 + ScaleTargetMemoryMebibytes *uint32 + ScaleTargetInFlightRequests *uint32 + ScaleTolerance *float64 + ScaleDownscaleStabilization *durationpb.Duration + ScaleInitialReadinessDelay *durationpb.Duration + // Zone policy (30-39). All placeholder. + ZoneSpread *ZoneSpread + ZoneMin *uint32 + ZoneAffinity *ZoneAffinity + // Assign policy (40-49). + AssignPath *string + AssignTimeout *durationpb.Duration + AssignTokenTtl *durationpb.Duration + // Heartbeat policy (50-59). + HeartbeatInterval *durationpb.Duration + HeartbeatTimeout *durationpb.Duration + // Retry policy (60-79). + RetryMaxAttempts *uint32 + RetryMinBackoff *durationpb.Duration + RetryMaxBackoff *durationpb.Duration + RetryBackpressure *Backpressure + RetryStatusCodes []uint32 + // Transport policy (80-109). All placeholder (followup: per-assignment-transport). + TransportDialTimeout *durationpb.Duration + TransportKeepalive *durationpb.Duration + TransportIdleConnTimeout *durationpb.Duration + TransportTlsHandshakeTimeout *durationpb.Duration + TransportMaxIdleConns *uint32 + TransportForceHttp2 *bool + TransportDisableCompression *bool + TransportFlushInterval *durationpb.Duration } func (b0 Assignment_builder) Build() *Assignment { @@ -582,26 +1478,100 @@ func (b0 Assignment_builder) Build() *Assignment { b, x := &b0, m0 _, _ = b, x if b.Namespace != nil { - protoimpl.X.SetPresentNonAtomic(&(x.XXX_presence[0]), 0, 6) + protoimpl.X.SetPresentNonAtomic(&(x.XXX_presence[0]), 0, 35) x.xxx_hidden_Namespace = b.Namespace } if b.Deployment != nil { - protoimpl.X.SetPresentNonAtomic(&(x.XXX_presence[0]), 1, 6) + protoimpl.X.SetPresentNonAtomic(&(x.XXX_presence[0]), 1, 35) x.xxx_hidden_Deployment = b.Deployment } if b.Tenant != nil { - protoimpl.X.SetPresentNonAtomic(&(x.XXX_presence[0]), 2, 6) + protoimpl.X.SetPresentNonAtomic(&(x.XXX_presence[0]), 2, 35) x.xxx_hidden_Tenant = b.Tenant } if b.Metadata != nil { - protoimpl.X.SetPresentNonAtomic(&(x.XXX_presence[0]), 3, 6) + protoimpl.X.SetPresentNonAtomic(&(x.XXX_presence[0]), 3, 35) x.xxx_hidden_Metadata = b.Metadata } x.xxx_hidden_Scale = b.Scale if b.Oneshot != nil { - protoimpl.X.SetPresentNonAtomic(&(x.XXX_presence[0]), 5, 6) + protoimpl.X.SetPresentNonAtomic(&(x.XXX_presence[0]), 5, 35) x.xxx_hidden_Oneshot = *b.Oneshot } + if b.ScaleMinInstances != nil { + protoimpl.X.SetPresentNonAtomic(&(x.XXX_presence[0]), 6, 35) + x.xxx_hidden_ScaleMinInstances = *b.ScaleMinInstances + } + if b.ScaleMaxInstances != nil { + protoimpl.X.SetPresentNonAtomic(&(x.XXX_presence[0]), 7, 35) + x.xxx_hidden_ScaleMaxInstances = *b.ScaleMaxInstances + } + if b.ScaleTargetCpuMillicores != nil { + protoimpl.X.SetPresentNonAtomic(&(x.XXX_presence[0]), 8, 35) + x.xxx_hidden_ScaleTargetCpuMillicores = *b.ScaleTargetCpuMillicores + } + if b.ScaleTargetMemoryMebibytes != nil { + protoimpl.X.SetPresentNonAtomic(&(x.XXX_presence[0]), 9, 35) + x.xxx_hidden_ScaleTargetMemoryMebibytes = *b.ScaleTargetMemoryMebibytes + } + if b.ScaleTargetInFlightRequests != nil { + protoimpl.X.SetPresentNonAtomic(&(x.XXX_presence[0]), 10, 35) + x.xxx_hidden_ScaleTargetInFlightRequests = *b.ScaleTargetInFlightRequests + } + if b.ScaleTolerance != nil { + protoimpl.X.SetPresentNonAtomic(&(x.XXX_presence[0]), 11, 35) + x.xxx_hidden_ScaleTolerance = *b.ScaleTolerance + } + x.xxx_hidden_ScaleDownscaleStabilization = b.ScaleDownscaleStabilization + x.xxx_hidden_ScaleInitialReadinessDelay = b.ScaleInitialReadinessDelay + if b.ZoneSpread != nil { + protoimpl.X.SetPresentNonAtomic(&(x.XXX_presence[0]), 14, 35) + x.xxx_hidden_ZoneSpread = *b.ZoneSpread + } + if b.ZoneMin != nil { + protoimpl.X.SetPresentNonAtomic(&(x.XXX_presence[0]), 15, 35) + x.xxx_hidden_ZoneMin = *b.ZoneMin + } + if b.ZoneAffinity != nil { + protoimpl.X.SetPresentNonAtomic(&(x.XXX_presence[0]), 16, 35) + x.xxx_hidden_ZoneAffinity = *b.ZoneAffinity + } + if b.AssignPath != nil { + protoimpl.X.SetPresentNonAtomic(&(x.XXX_presence[0]), 17, 35) + x.xxx_hidden_AssignPath = b.AssignPath + } + x.xxx_hidden_AssignTimeout = b.AssignTimeout + x.xxx_hidden_AssignTokenTtl = b.AssignTokenTtl + x.xxx_hidden_HeartbeatInterval = b.HeartbeatInterval + x.xxx_hidden_HeartbeatTimeout = b.HeartbeatTimeout + if b.RetryMaxAttempts != nil { + protoimpl.X.SetPresentNonAtomic(&(x.XXX_presence[0]), 22, 35) + x.xxx_hidden_RetryMaxAttempts = *b.RetryMaxAttempts + } + x.xxx_hidden_RetryMinBackoff = b.RetryMinBackoff + x.xxx_hidden_RetryMaxBackoff = b.RetryMaxBackoff + if b.RetryBackpressure != nil { + protoimpl.X.SetPresentNonAtomic(&(x.XXX_presence[0]), 25, 35) + x.xxx_hidden_RetryBackpressure = *b.RetryBackpressure + } + x.xxx_hidden_RetryStatusCodes = b.RetryStatusCodes + x.xxx_hidden_TransportDialTimeout = b.TransportDialTimeout + x.xxx_hidden_TransportKeepalive = b.TransportKeepalive + x.xxx_hidden_TransportIdleConnTimeout = b.TransportIdleConnTimeout + x.xxx_hidden_TransportTlsHandshakeTimeout = b.TransportTlsHandshakeTimeout + if b.TransportMaxIdleConns != nil { + protoimpl.X.SetPresentNonAtomic(&(x.XXX_presence[0]), 31, 35) + x.xxx_hidden_TransportMaxIdleConns = *b.TransportMaxIdleConns + } + if b.TransportForceHttp2 != nil { + protoimpl.X.SetPresentNonAtomic(&(x.XXX_presence[1]), 32, 35) + x.xxx_hidden_TransportForceHttp2 = *b.TransportForceHttp2 + } + if b.TransportDisableCompression != nil { + protoimpl.X.SetPresentNonAtomic(&(x.XXX_presence[1]), 33, 35) + x.xxx_hidden_TransportDisableCompression = *b.TransportDisableCompression + } + x.xxx_hidden_TransportFlushInterval = b.TransportFlushInterval return m0 } @@ -2046,13 +3016,13 @@ var File_types_proto protoreflect.FileDescriptor const file_types_proto_rawDesc = "" + "\n" + - "\vtypes.proto\x12\askipper\x1a!google/protobuf/go_features.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\xf8\x01\n" + + "\vtypes.proto\x12\askipper\x1a\x1egoogle/protobuf/duration.proto\x1a!google/protobuf/go_features.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\xf8\x01\n" + "\x05Scale\x12#\n" + "\rmin_instances\x18\x01 \x01(\rR\fminInstances\x12#\n" + "\rmax_instances\x18\x02 \x01(\rR\fmaxInstances\x123\n" + "\x16target_cpu_usage_milli\x18\x03 \x01(\rR\x13targetCpuUsageMilli\x125\n" + "\x17target_memory_usage_mib\x18\x04 \x01(\rR\x14targetMemoryUsageMib\x129\n" + - "\x19target_in_flight_requests\x18\x05 \x01(\rR\x16targetInFlightRequests\"\xbe\x01\n" + + "\x19target_in_flight_requests\x18\x05 \x01(\rR\x16targetInFlightRequests\"\xa2\x10\n" + "\n" + "Assignment\x12\x1c\n" + "\tnamespace\x18\x01 \x01(\tR\tnamespace\x12\x1e\n" + @@ -2062,7 +3032,39 @@ const file_types_proto_rawDesc = "" + "\x06tenant\x18\x03 \x01(\tR\x06tenant\x12\x1a\n" + "\bmetadata\x18\x04 \x01(\tR\bmetadata\x12$\n" + "\x05scale\x18\x05 \x01(\v2\x0e.skipper.ScaleR\x05scale\x12\x18\n" + - "\aoneshot\x18\x06 \x01(\bR\aoneshot\"\xce\x02\n" + + "\aoneshot\x18\x06 \x01(\bR\aoneshot\x12.\n" + + "\x13scale_min_instances\x18\n" + + " \x01(\rR\x11scaleMinInstances\x12.\n" + + "\x13scale_max_instances\x18\v \x01(\rR\x11scaleMaxInstances\x12=\n" + + "\x1bscale_target_cpu_millicores\x18\f \x01(\rR\x18scaleTargetCpuMillicores\x12A\n" + + "\x1dscale_target_memory_mebibytes\x18\r \x01(\rR\x1ascaleTargetMemoryMebibytes\x12D\n" + + "\x1fscale_target_in_flight_requests\x18\x0e \x01(\rR\x1bscaleTargetInFlightRequests\x12'\n" + + "\x0fscale_tolerance\x18\x0f \x01(\x01R\x0escaleTolerance\x12]\n" + + "\x1dscale_downscale_stabilization\x18\x10 \x01(\v2\x19.google.protobuf.DurationR\x1bscaleDownscaleStabilization\x12\\\n" + + "\x1dscale_initial_readiness_delay\x18\x11 \x01(\v2\x19.google.protobuf.DurationR\x1ascaleInitialReadinessDelay\x124\n" + + "\vzone_spread\x18\x1e \x01(\x0e2\x13.skipper.ZoneSpreadR\n" + + "zoneSpread\x12\x19\n" + + "\bzone_min\x18\x1f \x01(\rR\azoneMin\x12:\n" + + "\rzone_affinity\x18 \x01(\x0e2\x15.skipper.ZoneAffinityR\fzoneAffinity\x12\x1f\n" + + "\vassign_path\x18( \x01(\tR\n" + + "assignPath\x12@\n" + + "\x0eassign_timeout\x18) \x01(\v2\x19.google.protobuf.DurationR\rassignTimeout\x12C\n" + + "\x10assign_token_ttl\x18* \x01(\v2\x19.google.protobuf.DurationR\x0eassignTokenTtl\x12H\n" + + "\x12heartbeat_interval\x182 \x01(\v2\x19.google.protobuf.DurationR\x11heartbeatInterval\x12F\n" + + "\x11heartbeat_timeout\x183 \x01(\v2\x19.google.protobuf.DurationR\x10heartbeatTimeout\x12,\n" + + "\x12retry_max_attempts\x18< \x01(\rR\x10retryMaxAttempts\x12E\n" + + "\x11retry_min_backoff\x18= \x01(\v2\x19.google.protobuf.DurationR\x0fretryMinBackoff\x12E\n" + + "\x11retry_max_backoff\x18> \x01(\v2\x19.google.protobuf.DurationR\x0fretryMaxBackoff\x12D\n" + + "\x12retry_backpressure\x18? \x01(\x0e2\x15.skipper.BackpressureR\x11retryBackpressure\x12,\n" + + "\x12retry_status_codes\x18@ \x03(\rR\x10retryStatusCodes\x12O\n" + + "\x16transport_dial_timeout\x18P \x01(\v2\x19.google.protobuf.DurationR\x14transportDialTimeout\x12J\n" + + "\x13transport_keepalive\x18Q \x01(\v2\x19.google.protobuf.DurationR\x12transportKeepalive\x12X\n" + + "\x1btransport_idle_conn_timeout\x18R \x01(\v2\x19.google.protobuf.DurationR\x18transportIdleConnTimeout\x12`\n" + + "\x1ftransport_tls_handshake_timeout\x18S \x01(\v2\x19.google.protobuf.DurationR\x1ctransportTlsHandshakeTimeout\x127\n" + + "\x18transport_max_idle_conns\x18T \x01(\rR\x15transportMaxIdleConns\x122\n" + + "\x15transport_force_http2\x18U \x01(\bR\x13transportForceHttp2\x12B\n" + + "\x1dtransport_disable_compression\x18V \x01(\bR\x1btransportDisableCompression\x12S\n" + + "\x18transport_flush_interval\x18W \x01(\v2\x19.google.protobuf.DurationR\x16transportFlushInterval\"\xce\x02\n" + "\bInstance\x123\n" + "\n" + "assignment\x18\x01 \x01(\v2\x13.skipper.AssignmentR\n" + @@ -2120,7 +3122,23 @@ const file_types_proto_rawDesc = "" + "\x0econtroller_ips\x18\x03 \x03(\tR\rcontrollerIps\x12:\n" + "\vsupervisors\x18\x04 \x03(\v2\x18.skipper.SupervisorStateR\vsupervisors\x12&\n" + "\x06events\x18\x05 \x03(\v2\x0e.skipper.EventR\x06events\x12,\n" + - "\x06config\x18\x06 \x03(\v2\x14.skipper.ConfigValueR\x06config*\xc8\x01\n" + + "\x06config\x18\x06 \x03(\v2\x14.skipper.ConfigValueR\x06config*t\n" + + "\n" + + "ZoneSpread\x12\x1b\n" + + "\x17ZONE_SPREAD_UNSPECIFIED\x10\x00\x12\x14\n" + + "\x10ZONE_SPREAD_NONE\x10\x01\x12\x19\n" + + "\x15ZONE_SPREAD_PREFERRED\x10\x02\x12\x18\n" + + "\x14ZONE_SPREAD_REQUIRED\x10\x03*~\n" + + "\fZoneAffinity\x12\x1d\n" + + "\x19ZONE_AFFINITY_UNSPECIFIED\x10\x00\x12\x16\n" + + "\x12ZONE_AFFINITY_NONE\x10\x01\x12\x1b\n" + + "\x17ZONE_AFFINITY_PREFERRED\x10\x02\x12\x1a\n" + + "\x16ZONE_AFFINITY_REQUIRED\x10\x03*\x7f\n" + + "\fBackpressure\x12\x1c\n" + + "\x18BACKPRESSURE_UNSPECIFIED\x10\x00\x12\x17\n" + + "\x13BACKPRESSURE_IGNORE\x10\x01\x12\x16\n" + + "\x12BACKPRESSURE_RETRY\x10\x02\x12 \n" + + "\x1cBACKPRESSURE_RETRY_AND_EJECT\x10\x03*\xc8\x01\n" + "\vScaleReason\x12\x1c\n" + "\x18SCALE_REASON_UNSPECIFIED\x10\x00\x12\x14\n" + "\x10SCALE_REASON_CPU\x10\x01\x12\"\n" + @@ -2142,51 +3160,71 @@ const file_types_proto_rawDesc = "" + "\x13EVENT_SEVERITY_INFO\x10\x01\x12\x17\n" + "\x13EVENT_SEVERITY_WARN\x10\x02B8Z.github.com/gadget-inc/skipper/internal/skipper\x92\x03\x05\xd2>\x02\x10\x03b\beditionsp\xe8\a" -var file_types_proto_enumTypes = make([]protoimpl.EnumInfo, 3) +var file_types_proto_enumTypes = make([]protoimpl.EnumInfo, 6) var file_types_proto_msgTypes = make([]protoimpl.MessageInfo, 11) var file_types_proto_goTypes = []any{ - (ScaleReason)(0), // 0: skipper.ScaleReason - (EventType)(0), // 1: skipper.EventType - (EventSeverity)(0), // 2: skipper.EventSeverity - (*Scale)(nil), // 3: skipper.Scale - (*Assignment)(nil), // 4: skipper.Assignment - (*Instance)(nil), // 5: skipper.Instance - (*Heartbeat)(nil), // 6: skipper.Heartbeat - (*ScaleMetric)(nil), // 7: skipper.ScaleMetric - (*ScaleDecision)(nil), // 8: skipper.ScaleDecision - (*Event)(nil), // 9: skipper.Event - (*ConfigValue)(nil), // 10: skipper.ConfigValue - (*HeartbeatState)(nil), // 11: skipper.HeartbeatState - (*SupervisorState)(nil), // 12: skipper.SupervisorState - (*ClusterState)(nil), // 13: skipper.ClusterState - (*timestamppb.Timestamp)(nil), // 14: google.protobuf.Timestamp + (ZoneSpread)(0), // 0: skipper.ZoneSpread + (ZoneAffinity)(0), // 1: skipper.ZoneAffinity + (Backpressure)(0), // 2: skipper.Backpressure + (ScaleReason)(0), // 3: skipper.ScaleReason + (EventType)(0), // 4: skipper.EventType + (EventSeverity)(0), // 5: skipper.EventSeverity + (*Scale)(nil), // 6: skipper.Scale + (*Assignment)(nil), // 7: skipper.Assignment + (*Instance)(nil), // 8: skipper.Instance + (*Heartbeat)(nil), // 9: skipper.Heartbeat + (*ScaleMetric)(nil), // 10: skipper.ScaleMetric + (*ScaleDecision)(nil), // 11: skipper.ScaleDecision + (*Event)(nil), // 12: skipper.Event + (*ConfigValue)(nil), // 13: skipper.ConfigValue + (*HeartbeatState)(nil), // 14: skipper.HeartbeatState + (*SupervisorState)(nil), // 15: skipper.SupervisorState + (*ClusterState)(nil), // 16: skipper.ClusterState + (*durationpb.Duration)(nil), // 17: google.protobuf.Duration + (*timestamppb.Timestamp)(nil), // 18: google.protobuf.Timestamp } var file_types_proto_depIdxs = []int32{ - 3, // 0: skipper.Assignment.scale:type_name -> skipper.Scale - 4, // 1: skipper.Instance.assignment:type_name -> skipper.Assignment - 14, // 2: skipper.Instance.assigned_at:type_name -> google.protobuf.Timestamp - 14, // 3: skipper.Instance.ready_at:type_name -> google.protobuf.Timestamp - 4, // 4: skipper.Heartbeat.assignment:type_name -> skipper.Assignment - 14, // 5: skipper.Heartbeat.timestamp:type_name -> google.protobuf.Timestamp - 0, // 6: skipper.ScaleDecision.reason:type_name -> skipper.ScaleReason - 7, // 7: skipper.ScaleDecision.metrics:type_name -> skipper.ScaleMetric - 14, // 8: skipper.Event.timestamp:type_name -> google.protobuf.Timestamp - 4, // 9: skipper.Event.assignment:type_name -> skipper.Assignment - 1, // 10: skipper.Event.type:type_name -> skipper.EventType - 2, // 11: skipper.Event.severity:type_name -> skipper.EventSeverity - 6, // 12: skipper.HeartbeatState.heartbeat:type_name -> skipper.Heartbeat - 4, // 13: skipper.SupervisorState.assignment:type_name -> skipper.Assignment - 5, // 14: skipper.SupervisorState.instances:type_name -> skipper.Instance - 11, // 15: skipper.SupervisorState.router_heartbeats:type_name -> skipper.HeartbeatState - 14, // 16: skipper.ClusterState.started_at:type_name -> google.protobuf.Timestamp - 12, // 17: skipper.ClusterState.supervisors:type_name -> skipper.SupervisorState - 9, // 18: skipper.ClusterState.events:type_name -> skipper.Event - 10, // 19: skipper.ClusterState.config:type_name -> skipper.ConfigValue - 20, // [20:20] is the sub-list for method output_type - 20, // [20:20] is the sub-list for method input_type - 20, // [20:20] is the sub-list for extension type_name - 20, // [20:20] is the sub-list for extension extendee - 0, // [0:20] is the sub-list for field type_name + 6, // 0: skipper.Assignment.scale:type_name -> skipper.Scale + 17, // 1: skipper.Assignment.scale_downscale_stabilization:type_name -> google.protobuf.Duration + 17, // 2: skipper.Assignment.scale_initial_readiness_delay:type_name -> google.protobuf.Duration + 0, // 3: skipper.Assignment.zone_spread:type_name -> skipper.ZoneSpread + 1, // 4: skipper.Assignment.zone_affinity:type_name -> skipper.ZoneAffinity + 17, // 5: skipper.Assignment.assign_timeout:type_name -> google.protobuf.Duration + 17, // 6: skipper.Assignment.assign_token_ttl:type_name -> google.protobuf.Duration + 17, // 7: skipper.Assignment.heartbeat_interval:type_name -> google.protobuf.Duration + 17, // 8: skipper.Assignment.heartbeat_timeout:type_name -> google.protobuf.Duration + 17, // 9: skipper.Assignment.retry_min_backoff:type_name -> google.protobuf.Duration + 17, // 10: skipper.Assignment.retry_max_backoff:type_name -> google.protobuf.Duration + 2, // 11: skipper.Assignment.retry_backpressure:type_name -> skipper.Backpressure + 17, // 12: skipper.Assignment.transport_dial_timeout:type_name -> google.protobuf.Duration + 17, // 13: skipper.Assignment.transport_keepalive:type_name -> google.protobuf.Duration + 17, // 14: skipper.Assignment.transport_idle_conn_timeout:type_name -> google.protobuf.Duration + 17, // 15: skipper.Assignment.transport_tls_handshake_timeout:type_name -> google.protobuf.Duration + 17, // 16: skipper.Assignment.transport_flush_interval:type_name -> google.protobuf.Duration + 7, // 17: skipper.Instance.assignment:type_name -> skipper.Assignment + 18, // 18: skipper.Instance.assigned_at:type_name -> google.protobuf.Timestamp + 18, // 19: skipper.Instance.ready_at:type_name -> google.protobuf.Timestamp + 7, // 20: skipper.Heartbeat.assignment:type_name -> skipper.Assignment + 18, // 21: skipper.Heartbeat.timestamp:type_name -> google.protobuf.Timestamp + 3, // 22: skipper.ScaleDecision.reason:type_name -> skipper.ScaleReason + 10, // 23: skipper.ScaleDecision.metrics:type_name -> skipper.ScaleMetric + 18, // 24: skipper.Event.timestamp:type_name -> google.protobuf.Timestamp + 7, // 25: skipper.Event.assignment:type_name -> skipper.Assignment + 4, // 26: skipper.Event.type:type_name -> skipper.EventType + 5, // 27: skipper.Event.severity:type_name -> skipper.EventSeverity + 9, // 28: skipper.HeartbeatState.heartbeat:type_name -> skipper.Heartbeat + 7, // 29: skipper.SupervisorState.assignment:type_name -> skipper.Assignment + 8, // 30: skipper.SupervisorState.instances:type_name -> skipper.Instance + 14, // 31: skipper.SupervisorState.router_heartbeats:type_name -> skipper.HeartbeatState + 18, // 32: skipper.ClusterState.started_at:type_name -> google.protobuf.Timestamp + 15, // 33: skipper.ClusterState.supervisors:type_name -> skipper.SupervisorState + 12, // 34: skipper.ClusterState.events:type_name -> skipper.Event + 13, // 35: skipper.ClusterState.config:type_name -> skipper.ConfigValue + 36, // [36:36] is the sub-list for method output_type + 36, // [36:36] is the sub-list for method input_type + 36, // [36:36] is the sub-list for extension type_name + 36, // [36:36] is the sub-list for extension extendee + 0, // [0:36] is the sub-list for field type_name } func init() { file_types_proto_init() } @@ -2199,7 +3237,7 @@ func file_types_proto_init() { File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_types_proto_rawDesc), len(file_types_proto_rawDesc)), - NumEnums: 3, + NumEnums: 6, NumMessages: 11, NumExtensions: 0, NumServices: 0, diff --git a/internal/skipper/types.proto b/internal/skipper/types.proto index ccf690bb..52883351 100644 --- a/internal/skipper/types.proto +++ b/internal/skipper/types.proto @@ -2,6 +2,7 @@ edition = "2023"; package skipper; +import "google/protobuf/duration.proto"; import "google/protobuf/go_features.proto"; import "google/protobuf/timestamp.proto"; @@ -16,13 +17,98 @@ message Scale { uint32 target_in_flight_requests = 5; } +// ZoneSpread controls how the controller spreads instances across availability +// zones for an assignment. Placeholder (followup: zone-aware-placement); no +// decision site reads it in the current release. +enum ZoneSpread { + ZONE_SPREAD_UNSPECIFIED = 0; + ZONE_SPREAD_NONE = 1; + ZONE_SPREAD_PREFERRED = 2; + ZONE_SPREAD_REQUIRED = 3; +} + +// ZoneAffinity controls how the router prefers same-zone instances when routing +// requests for an assignment. Placeholder (followup: zone-aware-affinity); no +// decision site reads it in the current release. +enum ZoneAffinity { + ZONE_AFFINITY_UNSPECIFIED = 0; + ZONE_AFFINITY_NONE = 1; + ZONE_AFFINITY_PREFERRED = 2; + ZONE_AFFINITY_REQUIRED = 3; +} + +// Backpressure controls how the router responds when an upstream instance +// signals backpressure on a request. Placeholder (followup: backpressure); no +// decision site reads it in the current release. +enum Backpressure { + BACKPRESSURE_UNSPECIFIED = 0; + BACKPRESSURE_IGNORE = 1; + BACKPRESSURE_RETRY = 2; + BACKPRESSURE_RETRY_AND_EJECT = 3; +} + +// Assignment carries the per-tenant identity and policy for a deployment. +// Field numbers are grouped by concern, reserving ranges for future growth: +// 1-9 identity +// 10-29 scale (excluding scale = 5, grandfathered) +// 30-39 zone +// 40-49 assign +// 50-59 heartbeat +// 60-79 retry +// 80-109 transport +// 110-199 future message Assignment { + // Identity (1-9). string namespace = 1; string deployment = 2; string tenant = 3; string metadata = 4; + // reserved: scale = 5 (grandfathered, predates flat layout) Scale scale = 5; bool oneshot = 6; + + // Scale policy (10-29). The first five fields are flat aliases for the + // long-shipped Scale sub-message; resolvers prefer flat with fallback to + // Scale. The remaining three are flat-only additions. + uint32 scale_min_instances = 10; + uint32 scale_max_instances = 11; + uint32 scale_target_cpu_millicores = 12; + uint32 scale_target_memory_mebibytes = 13; + uint32 scale_target_in_flight_requests = 14; + double scale_tolerance = 15; + google.protobuf.Duration scale_downscale_stabilization = 16; + google.protobuf.Duration scale_initial_readiness_delay = 17; + + // Zone policy (30-39). All placeholder. + ZoneSpread zone_spread = 30; + uint32 zone_min = 31; + ZoneAffinity zone_affinity = 32; + + // Assign policy (40-49). + string assign_path = 40; // placeholder (followup: per-assignment-assign-path) + google.protobuf.Duration assign_timeout = 41; + google.protobuf.Duration assign_token_ttl = 42; + + // Heartbeat policy (50-59). + google.protobuf.Duration heartbeat_interval = 50; // placeholder (followup: per-assignment-heartbeat-interval) + google.protobuf.Duration heartbeat_timeout = 51; + + // Retry policy (60-79). + uint32 retry_max_attempts = 60; + google.protobuf.Duration retry_min_backoff = 61; + google.protobuf.Duration retry_max_backoff = 62; + Backpressure retry_backpressure = 63; // placeholder (followup: backpressure) + repeated uint32 retry_status_codes = 64; // placeholder (followup: backpressure) + + // Transport policy (80-109). All placeholder (followup: per-assignment-transport). + google.protobuf.Duration transport_dial_timeout = 80; + google.protobuf.Duration transport_keepalive = 81; + google.protobuf.Duration transport_idle_conn_timeout = 82; + google.protobuf.Duration transport_tls_handshake_timeout = 83; + uint32 transport_max_idle_conns = 84; + bool transport_force_http2 = 85; + bool transport_disable_compression = 86; + google.protobuf.Duration transport_flush_interval = 87; } message Instance {