Skip to content

feat: add exponential backoff for restart policy#394

Open
Syspretor wants to merge 7 commits into
sgl-project:mainfrom
Syspretor:feature/restart-policy-backoff
Open

feat: add exponential backoff for restart policy#394
Syspretor wants to merge 7 commits into
sgl-project:mainfrom
Syspretor:feature/restart-policy-backoff

Conversation

@Syspretor

@Syspretor Syspretor commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator

Summary

Implements the simplified restart policy enhancement from #380:

  • Preserves existing restart behavior: When RestartPolicy=RecreateRoleInstanceOnPodRestart triggers, all pods (including the crashed one) are deleted and recreated — unchanged.
  • Adds exponential backoff delay: Configurable baseDelaySeconds and maxDelaySeconds control the pacing between restart attempts. The delay grows as min(base * 2^restartCount, max), preventing infinite rapid restart loops.
  • Tracks restart history: RestartCount and LastRestartTime in RoleInstanceStatus persist across reconciles to compute backoff.

New API fields

Field Location Default Description
baseDelaySeconds LeaderWorkerPattern, CustomComponentsPattern 30 Base delay between restart attempts
maxDelaySeconds LeaderWorkerPattern, CustomComponentsPattern 600 Cap on exponential backoff

Example

spec:
  roles:
  - name: worker
    leaderWorkerPattern:
      restartPolicy: RecreateRoleInstanceOnPodRestart
      baseDelaySeconds: 30    # first retry after 30s
      maxDelaySeconds: 600    # cap at 10 minutes

Backoff progression (defaults)

Restart 0: 30s delay
Restart 1: 60s delay
Restart 2: 120s delay
Restart 3: 240s delay
Restart 4: 480s delay
Restart 5+: 600s (capped)

Test plan

  • Unit tests for calculateRestartDelay (exponential formula, overflow protection, cap)
  • Unit tests for checkRestartBackoff (no backoff on first restart, delay elapsed/not elapsed, RestartPolicy=None)
  • All existing unit tests pass (make test)
  • Lint passes (make lint)
  • E2E tests (requires Kind cluster)

Related

🤖 Generated with Claude Code

@Syspretor Syspretor requested a review from cheyang July 10, 2026 08:44

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces exponential backoff configurations (CrashPodRestartLimit, BaseDelaySeconds, and MaxDelaySeconds) for restart policies across workload patterns and role instances, tracking restart metrics in RoleInstanceStatus to pace recreation attempts. The review feedback highlights a critical integer overflow vulnerability in the backoff delay calculation, recommends implementing a mechanism to reset restart tracking once an instance has been stable for a prolonged period, and suggests adding defensive nil receiver checks to the new helper methods to prevent potential panics.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +397 to +410
func calculateRestartDelay(baseDelaySeconds, maxDelaySeconds, restartCount int32) int32 {
if baseDelaySeconds == 0 {
return 0
}
// Prevent overflow for large restart counts
if restartCount > 30 {
return maxDelaySeconds
}
delay := baseDelaySeconds * (1 << restartCount)
if maxDelaySeconds > 0 && delay > maxDelaySeconds {
return maxDelaySeconds
}
return delay
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

The current implementation of calculateRestartDelay is vulnerable to integer overflow.

Specifically, baseDelaySeconds * (1 << restartCount) is calculated using 32-bit signed integer arithmetic. If restartCount is large (e.g., 30) and baseDelaySeconds is 30, the multiplication will overflow int32 and wrap around to a negative value. This negative value will bypass the delay > maxDelaySeconds check and return a negative delay, which completely breaks the backoff mechanism and leads to rapid, infinite restart loops.

To prevent this, perform the calculation using int64 and cap the result appropriately before converting back to int32.

func calculateRestartDelay(baseDelaySeconds, maxDelaySeconds, restartCount int32) int32 {
	if baseDelaySeconds == 0 {
		return 0
	}
	// Prevent overflow for large restart counts
	if restartCount > 30 {
		if maxDelaySeconds > 0 {
			return maxDelaySeconds
		}
		return 2147483647
	}
	delay := int64(baseDelaySeconds) * (int64(1) << restartCount)
	if maxDelaySeconds > 0 && delay > int64(maxDelaySeconds) {
		return maxDelaySeconds
	}
	if delay > 2147483647 {
		return 2147483647
	}
	return int32(delay)
}

Comment on lines 83 to +92
func (c *realControl) calculateDiffsWithExpectation(ctx context.Context, updateInstance *workloadsv1alpha2.RoleInstance,
currentRevision, updateRevision *apps.ControllerRevision,
revisions []*apps.ControllerRevision, pods []*v1.Pod, inactivePods []*v1.Pod) (*expectationDiff, error) {

// Check restart backoff BEFORE any pod processing (including inactive pod cleanup).
// If the delay from the last restart hasn't elapsed, skip all scaling and requeue.
// This preserves crashed pods during the backoff window for log observability.
if requeue := c.checkRestartBackoff(ctx, updateInstance, pods, inactivePods); requeue > 0 {
return &expectationDiff{requeueAfter: requeue}, nil
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Currently, there is no mechanism to reset RestartCount and LastRestartTime when the instance becomes healthy and runs successfully for a long time. Without a reset mechanism, any future crash (even weeks or months later) will immediately suffer the maximum backoff delay, defeating the purpose of an "exponential" backoff.

To fix this, reset the restart tracking fields if the instance has been stable/healthy (no restart triggered) for a duration longer than max(maxDelaySeconds * 2, 10 minutes).

func (c *realControl) calculateDiffsWithExpectation(ctx context.Context, updateInstance *workloadsv1alpha2.RoleInstance,
	currentRevision, updateRevision *apps.ControllerRevision,
	revisions []*apps.ControllerRevision, pods []*v1.Pod, inactivePods []*v1.Pod) (*expectationDiff, error) {

	// Reset restart backoff tracking if the instance has been stable/healthy for a long time.
	if updateInstance.Status.RestartCount > 0 && updateInstance.Status.LastRestartTime != nil {
		maxDelay := updateInstance.Spec.GetMaxDelaySeconds()
		threshold := time.Duration(maxDelay) * time.Second * 2
		if threshold < 10*time.Minute {
			threshold = 10 * time.Minute
		}
		if time.Since(updateInstance.Status.LastRestartTime.Time) > threshold {
			updateInstance.Status.RestartCount = 0
			updateInstance.Status.LastRestartTime = nil
		}
	}

	// Check restart backoff BEFORE any pod processing (including inactive pod cleanup).
	// If the delay from the last restart hasn't elapsed, skip all scaling and requeue.
	// This preserves crashed pods during the backoff window for log observability.
	if requeue := c.checkRestartBackoff(ctx, updateInstance, pods, inactivePods); requeue > 0 {
		return &expectationDiff{requeueAfter: requeue}, nil
	}

Comment thread api/workloads/v1alpha2/helper.go Outdated
Comment on lines +301 to +333
func (r *RoleSpec) GetCrashPodRestartLimit() int32 {
if r.LeaderWorkerPattern != nil && r.LeaderWorkerPattern.CrashPodRestartLimit != nil {
return *r.LeaderWorkerPattern.CrashPodRestartLimit
}
if r.CustomComponentsPattern != nil && r.CustomComponentsPattern.CrashPodRestartLimit != nil {
return *r.CustomComponentsPattern.CrashPodRestartLimit
}
return DefaultCrashPodRestartLimit
}

// GetBaseDelaySeconds returns the configured base delay seconds,
// or the default (30) if not set.
func (r *RoleSpec) GetBaseDelaySeconds() int32 {
if r.LeaderWorkerPattern != nil && r.LeaderWorkerPattern.BaseDelaySeconds != nil {
return *r.LeaderWorkerPattern.BaseDelaySeconds
}
if r.CustomComponentsPattern != nil && r.CustomComponentsPattern.BaseDelaySeconds != nil {
return *r.CustomComponentsPattern.BaseDelaySeconds
}
return DefaultBaseDelaySeconds
}

// GetMaxDelaySeconds returns the configured max delay seconds,
// or the default (600) if not set.
func (r *RoleSpec) GetMaxDelaySeconds() int32 {
if r.LeaderWorkerPattern != nil && r.LeaderWorkerPattern.MaxDelaySeconds != nil {
return *r.LeaderWorkerPattern.MaxDelaySeconds
}
if r.CustomComponentsPattern != nil && r.CustomComponentsPattern.MaxDelaySeconds != nil {
return *r.CustomComponentsPattern.MaxDelaySeconds
}
return DefaultMaxDelaySeconds
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Add defensive nil receiver checks to GetCrashPodRestartLimit, GetBaseDelaySeconds, and GetMaxDelaySeconds to prevent potential nil pointer dereference panics if these methods are called on a nil *RoleSpec pointer (consistent with other helper methods like GetWorkloadType).

func (r *RoleSpec) GetCrashPodRestartLimit() int32 {
	if r == nil {
		return DefaultCrashPodRestartLimit
	}
	if r.LeaderWorkerPattern != nil && r.LeaderWorkerPattern.CrashPodRestartLimit != nil {
		return *r.LeaderWorkerPattern.CrashPodRestartLimit
	}
	if r.CustomComponentsPattern != nil && r.CustomComponentsPattern.CrashPodRestartLimit != nil {
		return *r.CustomComponentsPattern.CrashPodRestartLimit
	}
	return DefaultCrashPodRestartLimit
}

// GetBaseDelaySeconds returns the configured base delay seconds,
// or the default (30) if not set.
func (r *RoleSpec) GetBaseDelaySeconds() int32 {
	if r == nil {
		return DefaultBaseDelaySeconds
	}
	if r.LeaderWorkerPattern != nil && r.LeaderWorkerPattern.BaseDelaySeconds != nil {
		return *r.LeaderWorkerPattern.BaseDelaySeconds
	}
	if r.CustomComponentsPattern != nil && r.CustomComponentsPattern.BaseDelaySeconds != nil {
		return *r.CustomComponentsPattern.BaseDelaySeconds
	}
	return DefaultBaseDelaySeconds
}

// GetMaxDelaySeconds returns the configured max delay seconds,
// or the default (600) if not set.
func (r *RoleSpec) GetMaxDelaySeconds() int32 {
	if r == nil {
		return DefaultMaxDelaySeconds
	}
	if r.LeaderWorkerPattern != nil && r.LeaderWorkerPattern.MaxDelaySeconds != nil {
		return *r.LeaderWorkerPattern.MaxDelaySeconds
	}
	if r.CustomComponentsPattern != nil && r.CustomComponentsPattern.MaxDelaySeconds != nil {
		return *r.CustomComponentsPattern.MaxDelaySeconds
	}
	return DefaultMaxDelaySeconds
}

@Syspretor Syspretor force-pushed the feature/restart-policy-backoff branch from cb82a48 to 0b0a479 Compare July 10, 2026 08:53
@cheyang

cheyang commented Jul 13, 2026

Copy link
Copy Markdown
Collaborator

/copilot review

@cheyang

cheyang commented Jul 13, 2026

Copy link
Copy Markdown
Collaborator

@gemini-code-assist /review

@gemini-code-assist

Copy link
Copy Markdown

Warning

You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again!

return maxDelaySeconds
}
return delay
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This overflows int32 when restartCount is in the ~27-30 range (e.g., 30 * (1<<27) = 4B > int32 max). The current > 30 guard doesn't cover this window. The wrapped negative value then bypasses the max-cap check and gets returned as-is, effectively disabling backoff under sustained crashes.

Suggestion: do the shift in int64, cap, then convert back:

delay := int64(baseDelaySeconds) * (int64(1) << restartCount)
if maxDelaySeconds > 0 && delay > int64(maxDelaySeconds) {
    return maxDelaySeconds
}
if delay > math.MaxInt32 {
    return maxDelaySeconds
}
return int32(delay)

// This preserves crashed pods during the backoff window for log observability.
if requeue := c.checkRestartBackoff(ctx, updateInstance, pods, inactivePods); requeue > 0 {
return &expectationDiff{requeueAfter: requeue}, nil
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit (non-blocking): RestartCount only grows and is never reset. After the instance has been healthy for a long stretch (say, 2 * maxDelaySeconds), consider zeroing it out so a future one-off crash doesn't immediately land at the maximum backoff. Could be a follow-up PR.

ConditionType RoleInstanceConditionType `json:"conditionType"`
}

// Default values for restart policy delay configuration on RoleInstance.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: these duplicate DefaultBaseDelaySeconds / DefaultMaxDelaySeconds from helper.go with different names but identical values. Minor, but sharing one pair reduces the chance they drift out of sync.

When RestartPolicy=RecreateRoleInstanceOnPodRestart triggers a full
RoleInstance recreation on pod crash, add configurable exponential
backoff delay to prevent infinite rapid restart loops.

API changes:
- Add BaseDelaySeconds, MaxDelaySeconds fields to LeaderWorkerPattern
  and CustomComponentsPattern (RBG spec)
- Add corresponding fields to RoleInstanceSpec for propagation
- Add RestartCount and LastRestartTime to RoleInstanceStatus for
  tracking restart history

Controller changes:
- checkRestartBackoff: computes delay = min(base * 2^count, max)
  and requeues if delay not elapsed since LastRestartTime
- calculateRestartDelay: exponential backoff formula with overflow
  protection (following client-go workqueue convention)
- updateRestartTracking: increments RestartCount and sets
  LastRestartTime when recreation is triggered
- Scale() interface updated to return requeue duration

Defaults: baseDelaySeconds=30, maxDelaySeconds=600

Signed-off-by: Claude Opus 4.6 <noreply@anthropic.com>
@Syspretor Syspretor force-pushed the feature/restart-policy-backoff branch from 0b0a479 to f843b25 Compare July 13, 2026 02:29
Add comprehensive test coverage for the restart backoff feature:
- Envtest: RestartCount/LastRestartTime tracking, backoff delay verification,
  config propagation (RBG → RoleInstance), RestartCount reset after stable period
- E2E: backoff delays second recreation with real pod lifecycle, config
  propagation verification
- Refactored e2e restart_policy_stability.go into separate functions to reduce
  cyclomatic complexity
- Added WithBaseDelaySeconds/WithMaxDelaySeconds to LeaderWorkerRoleWrapper

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@cheyang

cheyang commented Jul 13, 2026

Copy link
Copy Markdown
Collaborator

/copilot review

@cheyang

cheyang commented Jul 13, 2026

Copy link
Copy Markdown
Collaborator

@gemini-code-assist /review

@gemini-code-assist

Copy link
Copy Markdown

Warning

You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again!

@cheyang

cheyang commented Jul 13, 2026

Copy link
Copy Markdown
Collaborator

Both previous blocking issues resolved (int32 overflow fixed with int64 arithmetic, e2e tests for backoff added). Implementation is solid. The unrelated CI failure in [RoleInstanceSet] surge maintains full readiness during rolling update is not caused by this PR. LGTM.

// Following client-go's workqueue.ItemExponentialFailureRateLimiter convention.
// When maxDelaySeconds is 0, no cap is applied (unbounded backoff).
func calculateRestartDelay(baseDelaySeconds, maxDelaySeconds, restartCount int32) int32 {
if baseDelaySeconds == 0 {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit (non-blocking): With restartCount >= 63, int64(1) << restartCount wraps to math.MinInt64, and the multiplication overflows int64 -- bypassing both cap checks. Practically unreachable with default maxDelay=600 (cap triggers at restartCount=5) plus the reset mechanism, but a one-liner if restartCount >= 62 { restartCount = 62 } before the shift would seal it completely.

玖宇 and others added 3 commits July 13, 2026 12:47
The Consistently check in runRestartBackoffDelayTest failed immediately
because the Restarting condition persisted in RoleInstance status during
the creation phase (scaling=true), causing checkRestartBackoff to skip
the backoff delay on the second crash.

Two fixes:
1. E2E test: add waitForInstanceFullyRecovered() that waits for
   RoleInstance Ready=True AND Restarting condition cleared before
   triggering the second crash.
2. Production: use in-memory restarting cache (isAlreadyRestarting) instead
   of persisted status (isInstanceRestarting) in checkRestartBackoff for
   more accurate restart-in-progress detection.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
ApplyRevision produces zero-value status fields for updateInstance.
When scaling=true during the pod creation phase (not crash-triggered
deletion), updateRestartTracking is NOT called, so updateInstance.Status
retains zero values for RestartCount and LastRestartTime. The unconditional
propagation from updateInstance to newStatus would overwrite the correct
values (loaded from the live object at reconcile start), resetting
RestartCount to 0 on every pod creation cycle.

This caused both envtest and e2e tests to fail because:
1. First crash: RestartCount=1 correctly persisted during deletion phase
2. Creation phase: unconditional propagation overwrote it to 0
3. Subsequent crash: checkRestartBackoff saw LastRestartTime=nil → no backoff

Fix: only propagate RestartCount/LastRestartTime from updateInstance when
they were actually modified (non-zero / non-nil), i.e., when
updateRestartTracking was called during the deletion phase.

Also revert checkRestartBackoff from isAlreadyRestarting back to
isInstanceRestarting (the in-memory cache has the same staleness issue)
and remove debug logging.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Two bugs were causing e2e test failures:

1. During backoff requeue (scaleRequeue > 0), the Restarting condition was
propagated from updateInstance to newStatus. This caused the Restarting
condition to be persisted alongside Ready=False (since pods are Failed).
On the next reconcile after backoff elapses, shouldRecreateInstance checks
wasInstanceReady(), which returns false when Ready=False, preventing
recreation from ever proceeding — the instance is stuck indefinitely.

Fix: do not propagate the Restarting condition during backoff requeue.
The condition should only be set when a restart is actually in progress
(scaling=true for deletion/creation), not while waiting for backoff.

2. The e2e Consistently check used filterActivePods() which excludes Failed
pods. During backoff, the original pods are Failed (not "active"), so
filterActivePods returns empty and the check fails immediately.

Fix: check that no pod has a UID different from the originals — any new
UID means recreation happened, which is the correct semantic for backoff.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@cheyang

cheyang commented Jul 14, 2026

Copy link
Copy Markdown
Collaborator

/copilot review

@cheyang

cheyang commented Jul 14, 2026

Copy link
Copy Markdown
Collaborator

@gemini-code-assist /review

@gemini-code-assist

Copy link
Copy Markdown

Warning

You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again!

// was triggered). ApplyRevision produces zero-value status fields, so
// unconditional propagation would clobber the correct values from the
// live object during the pod creation phase.
if updateInstance.Status.RestartCount > 0 {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This push introduces the conditional propagation if updateInstance.Status.RestartCount > 0 { newStatus.RestartCount = updateInstance.Status.RestartCount } and the matching guard for LastRestartTime. It only fires while scaling == true, which is only true on the reconcile where updateRestartTracking actually incremented the counter. On the very next reconcile (pods already deleted / being recreated, or Ready again) scaling is false, so we skip the block entirely and UpdateInstanceStatus writes the freshly-initialized newStatus.RestartCount = 0 / LastRestartTime = nil back to the API server. The counter is effectively zeroed one reconcile after each restart, which breaks backoff exactly when it needs to kick in.

CI on this SHA reflects the regression:

  • envtest TestRestartPolicy – three new Exponential Backoff cases time out at 60s:
    • should track RestartCount and LastRestartTime after recreation (line 867)
    • should delay second recreation due to backoff (line 930)
    • should reset RestartCount after stable period (line 1060)
  • e2e restart policy stabilityRecreateRoleInstanceOnPodRestart with backoff delays second recreation fails after ~1s at line 345 (pod gets recreated during the backoff window). Repeats deterministically in both e2e-test and e2e-test-manifest matrix jobs.

These are the tests this PR itself added, so the failure is a direct signal that the new propagation path is losing state, not that the tests are flaky. The right fix is likely to either propagate RestartCount / LastRestartTime unconditionally from the live instance (or from updateInstance after ApplyRevision) even when scaling == false, or to seed newStatus from the current instance.Status for these two fields before Scale runs. Please restore persistence and re-run envtest+e2e locally before pushing again.

elapsed := time.Since(instance.Status.LastRestartTime.Time)
remaining := time.Duration(delay)*time.Second - elapsed
if remaining > 0 {
klog.Infof("Restart backoff: instance %s waiting %v (restartCount=%d, delay=%ds)",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The backoff-waiting message is now logged at Info instead of V(2). For a role instance in a genuine crash loop this reconciles on every backoff tick, so the operator log will get one line per instance per requeue. Not a correctness issue, but worth double-checking whether Info is the right level here — V(1) or V(2) is closer to the surrounding conventions and avoids drowning out other events during long backoff windows.

return true
}
// Slow path: read fresh from API server (bypasses informer cache)
if c.apiReader == nil {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The added if c.apiReader == nil { return false } guard is a sensible defensive check and matches the pattern used elsewhere for unit-testable controls. No action needed.

玖宇 and others added 2 commits July 14, 2026 11:54
checkRestartBackoff now reads RestartCount and LastRestartTime from the
API server directly, preventing stale informer data from causing backoff
to be skipped. Additionally, updateStatus preserves live restart tracking
values to prevent a stale-cache reconcile from rolling back a RestartCount
that was already incremented by a prior reconcile.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…rt backoff

Three informer-cache staleness issues were causing backoff and recreation
to fail:

1. checkRestartBackoff read the Restarting condition from the stale informer
   cache. A stale Restarting=True (from a previous restart that completed)
   incorrectly skipped backoff. Now reads fresh from API server.

2. shouldRecreateInstance checked wasInstanceReady() which returned false
   after the backoff status update set Ready=False. Added a bypass when
   LastRestartTime is set (proving the instance was previously stable).

3. calculateDiffsWithExpectation read LastRestartTime from the stale informer
   cache. When nil, the wasInstanceReady bypass didn't trigger. Now reads
   fresh from API server when the informer shows LastRestartTime=nil.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds configurable exponential backoff to the existing RecreateRoleInstanceOnPodRestart restart policy by persisting restart history (RestartCount, LastRestartTime) on RoleInstanceStatus, propagating delay settings from higher-level specs, and requeueing reconciles when the backoff window hasn’t elapsed.

Changes:

  • Introduces baseDelaySeconds / maxDelaySeconds API fields (LeaderWorker/CustomComponents patterns + RoleInstance spec) and propagates them into RoleInstances.
  • Implements restart backoff logic in the RoleInstance scale path, including status tracking of restart history.
  • Expands envtest + e2e coverage for restart backoff behavior and propagation.

Reviewed changes

Copilot reviewed 16 out of 23 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
test/wrappers/v1alpha2/role_wrapper.go Adds wrapper helpers to set baseDelaySeconds / maxDelaySeconds in tests.
test/stress/scenario.go Formatting/alignment changes to the stress Scenario struct fields.
test/envtest/testcase/restart_policy/restart_policy_test.go Adds envtest integration cases for backoff tracking, delay, propagation, and reset behavior.
test/e2e/testcase/v1alpha2/restart_policy_stability.go Refactors restart policy e2e tests into helpers and adds backoff-focused e2e cases.
pkg/reconciler/roleinstanceset_reconciler.go Propagates restart policy delay settings into RoleInstance templates via applyconfig.
pkg/reconciler/roleinstance/sync/instance_scale.go Core implementation: backoff requeue path, restart tracking updates, and delay calculation.
pkg/reconciler/roleinstance/sync/instance_scale_test.go Adds unit tests for delay calculation and backoff decision logic.
pkg/reconciler/roleinstance/sync/api.go Updates sync interface to return an additional requeueAfter duration from Scale.
pkg/reconciler/roleinstance/instance_status.go Ensures status updates treat restart tracking as part of inconsistency detection and avoid clobbering newer values.
pkg/reconciler/roleinstance/instance_reconciler.go Wires Scale requeue duration into reconcile flow; propagates restart tracking during active scaling.
deploy/kubectl/manifests.yaml Updates generated manifests to include new API fields and status fields.
config/crd/bases/workloads.x-k8s.io_roleinstances.yaml Adds RoleInstance CRD schema for delay fields + restart tracking status fields.
config/crd/bases/workloads.x-k8s.io_rolebasedgroupsets.yaml Adds delay field schema to RBGSets patterns in the CRD.
config/crd/bases/workloads.x-k8s.io_rolebasedgroups.yaml Adds delay field schema to RBG patterns in the CRD.
client-go/applyconfiguration/workloads/v1alpha2/roleinstancetemplate.go Adds applyconfig setters for RoleInstance template delay fields.
client-go/applyconfiguration/workloads/v1alpha2/roleinstancestatus.go Adds applyconfig support for restartCount and lastRestartTime status fields.
client-go/applyconfiguration/workloads/v1alpha2/roleinstancespec.go Adds applyconfig setters for RoleInstance spec delay fields.
client-go/applyconfiguration/workloads/v1alpha2/leaderworkerpattern.go Adds applyconfig setters for pattern delay fields.
client-go/applyconfiguration/workloads/v1alpha2/customcomponentspattern.go Adds applyconfig setters for pattern delay fields.
api/workloads/v1alpha2/zz_generated.deepcopy.go Updates deep-copies to include new delay fields and LastRestartTime.
api/workloads/v1alpha2/roleinstance_types.go Adds RoleInstance spec delay fields + status restart tracking, plus getters with defaults.
api/workloads/v1alpha2/rolebasedgroup_types.go Adds delay configuration fields to LeaderWorker/CustomComponents patterns with defaults.
api/workloads/v1alpha2/helper.go Introduces default delay constants + RoleSpec helper getters for delay settings.
Files not reviewed (6)
  • api/workloads/v1alpha2/zz_generated.deepcopy.go: Generated file
  • client-go/applyconfiguration/workloads/v1alpha2/customcomponentspattern.go: Generated file
  • client-go/applyconfiguration/workloads/v1alpha2/leaderworkerpattern.go: Generated file
  • client-go/applyconfiguration/workloads/v1alpha2/roleinstancespec.go: Generated file
  • client-go/applyconfiguration/workloads/v1alpha2/roleinstancestatus.go: Generated file
  • client-go/applyconfiguration/workloads/v1alpha2/roleinstancetemplate.go: Generated file

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +454 to +470
func calculateRestartDelay(baseDelaySeconds, maxDelaySeconds, restartCount int32) int32 {
if baseDelaySeconds == 0 {
return 0
}
// Use int64 arithmetic to prevent overflow for large restart counts.
// Example: baseDelay=30, restartCount=27 → 30 * 2^27 = 4B > int32 max.
delay := int64(baseDelaySeconds) * (int64(1) << restartCount)
// Apply maxDelay cap if configured
if maxDelaySeconds > 0 && delay > int64(maxDelaySeconds) {
return maxDelaySeconds
}
// If no cap and delay exceeds int32, cap at int32 max to prevent overflow
if delay > int64(0x7FFFFFFF) {
return 0x7FFFFFFF
}
return int32(delay)
}
Comment on lines +393 to +396
// No previous restart tracking from informer — check fresh data below
if instance.Status.LastRestartTime == nil {
// Also check fresh from API server below
}
Comment on lines +431 to +433
if lastRestartTime == nil {
return 0
}

@cheyang cheyang left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the PR — the direction is right, and introducing backoff addresses the "instant furious restart" problem. Nice touches too: the int64 overflow guard and following client-go's ItemExponentialFailureRateLimiter convention.

Against issue #363, I left a few inline suggestions below. Three that don't map to a specific line:

  • No durable crash forensics for the unattended case: preserving the pod during the backoff window is great for live kubectl logs -p, but once the window closes the evidence is gone. Consider recording podName/node/container/exitCode/reason/finishedAt into status (e.g. LastCrashInfo) before the rebuild, so a 3am crash can still be diagnosed the next morning.
  • Consider grouping the API fields: baseDelaySeconds/maxDelaySeconds are flat on both patterns; if more recovery-related knobs get added later, a RestartPolicyConfig sub-struct would be easier to extend and wouldn't touch the RestartPolicy field type.
  • The defaults change recovery behavior: base=30/max=600 turns "rebuild immediately on crash" into "wait 30s first, escalating to 10min". Worth calling this default change out in the PR description, or consider a more conservative default (e.g. backoff off by default / opt-in).

Nice starting point overall.

// meaning the instance WAS stable before. The status update during backoff may have set
// Ready=False (because pods are Failed), so wasInstanceReady() returns false. We bypass
// this check to allow the recreation to proceed after backoff expires.
if (!wasInstanceReady(instance) && instance.Status.LastRestartTime == nil) ||

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 This weakens the Ready invariant of shouldRecreateInstance and may regress.

Once LastRestartTime has been set even once, the instance will trigger a rebuild even if it was never Ready. For example, a pod that keeps crashing during init and never reaches Ready would keep satisfying the recreate condition.

Suggest not using LastRestartTime != nil to permanently bypass the Ready gate. Instead, use an explicit "recovering" state (the Restarting condition): relax the Ready check only while that state is active, and clear it once recovery completes.

restarting := isInstanceRestarting(instance)
if c.apiReader != nil {
fresh := &workloadsv1alpha2.RoleInstance{}
if err := c.apiReader.Get(ctx, client.ObjectKeyFromObject(instance), fresh); err == nil {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 State model doesn't fit the reconcile loop well: several apiReader.Get direct reads.

Here (and at lines 112, 485) you read straight from the API server to work around the informer cache, potentially multiple times per reconcile. That's usually a sign the state has more than one source of truth, and it adds apiserver load.

Suggest anchoring the backoff on the Restarting condition's LastTransitionTime (naturally visible within reconcile), combined with the in-memory restartingCache for informer lag, so you don't need a fresh read every round.

}
// Use int64 arithmetic to prevent overflow for large restart counts.
// Example: baseDelay=30, restartCount=27 → 30 * 2^27 = 4B > int32 max.
delay := int64(baseDelaySeconds) * (int64(1) << restartCount)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Overflow at very large restartCount.

If restartCount grows to 63+: int64(1) << 63 goes negative and << 64 becomes 0, so the computed delay turns negative or zero → backoff silently disappears.

Suggest moving the comparison before the shift: if restartCount is already large enough that base << count must exceed max, return max directly; or cap the shift count (e.g. via bits.Len).

if stableThreshold < 10*time.Minute {
stableThreshold = 10 * time.Minute
}
if time.Since(instance.Status.LastRestartTime.Time) > stableThreshold {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The RestartCount reset semantics are a bit fragile.

This only resets "when the next restart happens", based on "time since the last restart", rather than "the instance has stayed Ready". A crashloop where each rebuild takes longer than maxDelay*2 before crashing again would get reset every round.

Suggest gating the reset on wasInstanceReady plus a sustained stability window (with hysteresis), so a flapping recovery can't keep zeroing the counter.

return 0
}
// No previous restart tracking from informer — check fresh data below
if instance.Status.LastRestartTime == nil {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Empty dead code.

This if body is empty with only a comment — no actual behavior. Suggest removing it, or filling in the intended fresh-read logic.

@cheyang cheyang requested a review from diw-zw July 14, 2026 13:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Enhancement] RecreateRoleInstanceOnPodRestart destroys observability and causes infinite recreate loops

3 participants