Fix: wrap status update with RetryOnConflict to prevent linkedSkills loss#423
Conversation
huang195
left a comment
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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")) |
There was a problem hiding this comment.
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.
huang195
left a comment
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
nit — rt.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.
…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>
fe85e25 to
1432569
Compare
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
left a comment
There was a problem hiding this comment.
LGTM and E2E is green. Nice work!
Summary
step 8) withretry.RetryOnConflictto handle optimistic concurrency conflictslinkedSkillsandSkillsDiscoveredcondition assignment inside the retry loop so they survive a re-fetchlinkedSkillspersists through a conflictRoot 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
linkedSkillsfield 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 (seeapplyWorkloadConfig,removeLabelsOnDelete, etc.). Inside the retry closure:resourceVersionStatus().Update()Closes #422
Test plan
mainrun)Assisted-By: Claude Code