Skip to content

Fix: wrap status update with RetryOnConflict to prevent linkedSkills loss#423

Merged
pdettori merged 4 commits into
rossoctl:mainfrom
pdettori:fix/reconciler-conflict-retry-422
Jun 10, 2026
Merged

Fix: wrap status update with RetryOnConflict to prevent linkedSkills loss#423
pdettori merged 4 commits into
rossoctl:mainfrom
pdettori:fix/reconciler-conflict-retry-422

Conversation

@pdettori

Copy link
Copy Markdown
Member

Summary

  • Wraps the reconciler's final status update (step 8) with retry.RetryOnConflict to handle optimistic concurrency conflicts
  • Moves linkedSkills and SkillsDiscovered condition assignment inside the retry loop so they survive a re-fetch
  • Adds a unit test simulating a concurrent status modification to verify linkedSkills persists through a conflict

Root Cause

When another reconciliation loop (e.g., applying labels/config-hash) modifies the same AgentRuntime object concurrently, the status update fails with a conflict error. Without retry, the linkedSkills field is never populated, causing the Skill Discovery E2E test to time out after 180s.

Approach

Uses the same retry.RetryOnConflict(retry.DefaultRetry, ...) pattern already established throughout this controller (see applyWorkloadConfig, removeLabelsOnDelete, etc.). Inside the retry closure:

  1. Re-fetches the AgentRuntime to get the latest resourceVersion
  2. Re-applies all status fields (configuredPods, phase, Ready condition, linkedSkills, SkillsDiscovered condition)
  3. Calls Status().Update()

Closes #422

Test plan

  • Unit test: new "should persist linkedSkills even after a concurrent status modification" test passes
  • Unit test: existing "should read linked skills into status" test still passes
  • CI: E2E skill discovery tests pass (previously failing on every main run)

Assisted-By: Claude Code

@pdettori
pdettori requested a review from a team as a code owner June 10, 2026 11:54

@huang195 huang195 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Review Summary

The diagnosis and the RetryOnConflict approach are spot-on, Paolo — bellissimo reuse of the established controller pattern, and the root-cause writeup is chef's-kiss. The catch: re-fetching rt inside the retry closure throws away the conditions set earlier in the same reconcile that weren't persisted yet, so linkedSkills is saved but ConfigResolved and IstioMeshEnrolled are lost. The unit-test suite caught it (2 failing specs in a file this PR doesn't even touch — that's the tell).

Two CI gates to clear:

  • Unit Tests (fail) — the condition-drop regression (see inline). The real blocker.
  • verify-pr-title (fail) — title must use a capitalized prefix. fix:Fix: (the check is case-sensitive: Build, Chore, CI, Docs, Feat, Fix, ...).
  • E2E Tests (fail) — this is the suite the PR targets; worth confirming it goes green once the condition-drop is fixed (the test-plan checkbox for it is still unchecked).

Areas reviewed: Go (controller + test) · Commits: 1, signed-off ✅ · CI: failing (Unit, E2E, pr-title)

Fix the re-fetch to preserve all conditions and that title prefix, and this is good to go. 🍝

Assisted-By: Claude Code

if err := r.Status().Update(ctx, rt); err != nil {
// 8. Update status (retry on conflict to avoid losing linkedSkills updates)
if err := retry.RetryOnConflict(retry.DefaultRetry, func() error {
if err := r.Get(ctx, req.NamespacedName, rt); err != nil {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

must-fix — This r.Get(ctx, req.NamespacedName, rt) overwrites the entire in-memory rt (including rt.Status.Conditions) with server state. But ConfigResolved (set at step 5) and IstioMeshEnrolled (step 4.6) are set in-memory and not yet persisted when we reach step 8 — the re-fetch discards them, and the closure only re-applies Ready, SkillsDiscovered, ConfiguredPods, LinkedSkills. So those conditions are silently dropped on every reconcile.

This is exactly what breaks the two agentruntime_config_test.go specs (lines 348 & 386 — ConfigResolved comes back nil).

Suggest snapshotting the desired status before the loop and restoring it after the fresh Get:

desired := rt.Status.DeepCopy()
if err := retry.RetryOnConflict(retry.DefaultRetry, func() error {
    if err := r.Get(ctx, req.NamespacedName, rt); err != nil {
        return err
    }
    rt.Status = *desired // keeps refreshed resourceVersion, preserves all computed conditions
    rt.Status.ConfiguredPods = configuredPods
    // ... existing Ready / LinkedSkills / SkillsDiscovered logic ...
    return r.Status().Update(ctx, rt)
}); err != nil { ... }


updatedRT := &agentv1alpha1.AgentRuntime{}
Expect(k8sClient.Get(ctx, nn, updatedRT)).To(Succeed())
Expect(updatedRT.Status.LinkedSkills).To(ConsistOf("skill-a", "skill-b"))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

suggestion — This new test only asserts LinkedSkills survives the conflict, which is why it stayed green while the ConfigResolved / IstioMeshEnrolled regression slipped through. Adding an assertion that ConfigResolved (and ideally IstioMeshEnrolled) is still present after the conflicting reconcile would lock down the whole status-preservation contract, not just the one field.

@pdettori pdettori changed the title fix: wrap status update with RetryOnConflict to prevent linkedSkills loss Fix: wrap status update with RetryOnConflict to prevent linkedSkills loss Jun 10, 2026

@huang195 huang195 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Review Summary (re-review)

Perfetto, Paolo — this is exactly the fix. Snapshotting rt.Status.DeepCopy() before the retry loop and restoring it after the fresh Get preserves ConfigResolved, IstioMeshEnrolled, and the rest while still getting the refreshed resourceVersion for a conflict-free update. And bellissimo touch adding the ConfigResolved-survives-the-retry assertion to the new test — that locks down the whole status-preservation contract, not just linkedSkills. Unit Tests, pr-title, and lint are all green now.

Only open item: E2E is still running (pending) — since that's the suite this PR ultimately targets (skill discovery timeout), worth a glance once it lands before merge.

Areas reviewed: Go (controller + test) · Commits: 2, all signed-off ✅ · CI: all green except E2E (pending)

No blockers — approving. 🍝

Assisted-By: Claude Code

if err := r.Get(ctx, req.NamespacedName, rt); err != nil {
return err
}
rt.Status = *desired

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

nitrt.Status = *desired is last-writer-wins on the entire status: any field a concurrent actor wrote that this reconcile didn't recompute gets overwritten. That's correct here since this controller is the sole owner of AgentRuntime status, but a one-line comment noting that intent would save a future reader from mistaking it for an oversight.

pdettori added 3 commits June 10, 2026 10:21
…loss

The reconciler's final status update could fail with an optimistic
concurrency conflict when another reconciliation loop modified the same
AgentRuntime concurrently. Without retry, the linkedSkills field was
never populated after the conflict, causing the E2E test to time out.

Wrap the status update with retry.RetryOnConflict (the same pattern used
throughout this controller) so the update re-fetches the latest object
and re-applies all status fields on conflict.

Closes rossoctl#422

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
Address review feedback: the re-fetch inside RetryOnConflict was
discarding conditions computed earlier in the same reconcile
(ConfigResolved, IstioMeshEnrolled) because they weren't persisted yet.

Snapshot rt.Status.DeepCopy() before the retry loop and restore it after
the fresh Get — this preserves all computed conditions while still
picking up the latest resourceVersion for the update.

Also add ConfigResolved assertion to the conflict unit test to lock down
the full status-preservation contract.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
@pdettori
pdettori force-pushed the fix/reconciler-conflict-retry-422 branch from fe85e25 to 1432569 Compare June 10, 2026 14:22
The deletion handler was removing the kagenti.io/skills annotation from
the target Deployment. This annotation is user-managed (set by the
kagenti backend or user) — the operator reads but never sets it, so it
should not remove it either.

This caused the E2E skill discovery test to fail: the "feature gate
disabled" test deletes the AgentRuntime, the finalizer strips the
annotation, and the subsequent "feature gate enabled" test finds an
empty annotation on the same Deployment.

Also re-apply the Deployment fixture in the E2E "feature gate enabled"
BeforeAll to guard against any future annotation-stripping regressions.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>

@kevincogan kevincogan 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.

LGTM and E2E is green. Nice work!

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.

Fix: reconciler conflict error prevents linkedSkills status update

3 participants