-
Notifications
You must be signed in to change notification settings - Fork 76
fix(nvsnap): capture watcher and cachedir restore shim, so a restored pod actually serves #2101
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
189b87a
67d15d8
56a3d7b
82dcc79
4f203c7
b512921
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -503,4 +503,3 @@ func tailOfFile(path string, n int) string { | |
| } | ||
| return strings.Join(lines, " | ") | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,62 @@ | ||
| // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| package agent | ||
|
|
||
| import ( | ||
| "context" | ||
| "testing" | ||
|
|
||
| "github.com/sirupsen/logrus" | ||
| corev1 "k8s.io/api/core/v1" | ||
| storagev1 "k8s.io/api/storage/v1" | ||
| metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" | ||
| "k8s.io/client-go/kubernetes/fake" | ||
| ) | ||
|
|
||
| // The resolver returns the profile next to the promoter so the webhook can | ||
| // read the prewarm policy, and a nvsnap-storage-profiles ConfigMap entry | ||
| // that turns the prewarm off for a provisioner reaches it unchanged. | ||
| func TestResolveL2Promoter_ReturnsProfileWithPrewarmPolicy(t *testing.T) { | ||
| log := logrus.New() | ||
| log.SetLevel(logrus.PanicLevel) | ||
| sc := &storagev1.StorageClass{ | ||
| ObjectMeta: metav1.ObjectMeta{Name: "nvcf-sc"}, | ||
| Provisioner: "nvmesh-csi.excelero.com", | ||
| } | ||
|
|
||
| kc := fake.NewSimpleClientset(sc) | ||
| promoter, profile := resolveL2Promoter(context.Background(), kc, nil, "nvcf-sc", "nvsnap-system", log) | ||
| if promoter == nil || profile == nil { | ||
| t.Fatalf("built-in NVMesh profile: promoter=%v profile=%v, want both", promoter, profile) | ||
| } | ||
| if !profile.PrewarmEnabled() || profile.PrewarmWorkers() != 6 { | ||
| t.Errorf("built-in profile ships prewarm on with 6 readers, got enabled=%v workers=%d", profile.PrewarmEnabled(), profile.PrewarmWorkers()) | ||
| } | ||
|
|
||
| cm := &corev1.ConfigMap{ | ||
| ObjectMeta: metav1.ObjectMeta{Name: storageProfilesConfigMap, Namespace: "nvsnap-system"}, | ||
| Data: map[string]string{"profiles.yaml": ` | ||
| nvmesh-csi.excelero.com: | ||
| strategy: shared-volume | ||
| volumeHandleTransform: nvmesh | ||
| mountOptions: [ro, norecovery, nouuid] | ||
| prewarm: false | ||
| prewarmParallelism: 2 | ||
| `}, | ||
| } | ||
| kc = fake.NewSimpleClientset(sc, cm) | ||
| promoter, profile = resolveL2Promoter(context.Background(), kc, nil, "nvcf-sc", "nvsnap-system", log) | ||
| if promoter == nil || profile == nil { | ||
| t.Fatalf("ConfigMap overlay: promoter=%v profile=%v, want both", promoter, profile) | ||
| } | ||
| if profile.PrewarmEnabled() || profile.PrewarmWorkers() != 2 { | ||
| t.Errorf("ConfigMap prewarm policy lost on the way to the webhook: enabled=%v workers=%d", profile.PrewarmEnabled(), profile.PrewarmWorkers()) | ||
| } | ||
|
|
||
| // No match: nothing to hand the webhook, it falls back to defaults. | ||
| unknown := &storagev1.StorageClass{ObjectMeta: metav1.ObjectMeta{Name: "weka"}, Provisioner: "csi.weka.io"} | ||
| if _, profile := resolveL2Promoter(context.Background(), fake.NewSimpleClientset(unknown), nil, "weka", "nvsnap-system", log); profile != nil { | ||
| t.Errorf("unmatched provisioner must yield a nil profile, got %+v", profile) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -145,10 +145,13 @@ func (w *Watcher) handlePodEvent(ctx context.Context, obj any) { | |
| if !ok || pod == nil { | ||
| return | ||
| } | ||
| plog := w.logger().WithField("pod", pod.Namespace+"/"+pod.Name) | ||
| if !w.isLabeledForCapture(pod) { | ||
| plog.Debug("watcher: skipping, not labeled for capture") | ||
| return | ||
| } | ||
| if !IsPodReady(pod) { | ||
| plog.Debug("watcher: skipping, pod not Ready yet") | ||
| return | ||
| } | ||
| // Rootfs-only capture is the multi-GPU fallback (cuda-checkpoint can't | ||
|
|
@@ -165,15 +168,18 @@ func (w *Watcher) handlePodEvent(ctx context.Context, obj any) { | |
| // signal (the watch already filters by it at line 130), so we honor it | ||
| // even for single-GPU pods and let rootfs replace CRIU. | ||
| gpus := podGPURequest(pod) | ||
| if gpus < 2 { | ||
| w.logger().WithFields(logrus.Fields{ | ||
| "pod": pod.Namespace + "/" + pod.Name, | ||
| "gpus": gpus, | ||
| }).Info("rootfs-only watcher capturing single-GPU pod (explicit nvsnap.io/capture=true opt-in)") | ||
| } | ||
| if _, alreadyScheduled := w.captured.LoadOrStore(pod.UID, struct{}{}); alreadyScheduled { | ||
| plog.Debug("watcher: skipping, a capture is already scheduled or committed for this pod UID") | ||
| return | ||
| } | ||
| // Logged for every pod, not only single-GPU ones. This used to fire only | ||
| // when gpus < 2, so a multi-GPU pod -- the case this path exists to serve | ||
| // -- produced no output on any branch, and a capture that silently never | ||
| // happened was indistinguishable from one never triggered. | ||
| plog.WithFields(logrus.Fields{ | ||
| "gpus": gpus, | ||
| "warmup": w.WarmupDelay, | ||
| }).Info("watcher: scheduling rootfs capture (nvsnap.io/capture=true)") | ||
| go w.runCapture(ctx, pod.DeepCopy()) | ||
| } | ||
|
|
||
|
|
@@ -228,6 +234,19 @@ func (w *Watcher) refreshPodForCapture(ctx context.Context, pod *corev1.Pod, log | |
| // retried by re-firing on subsequent Pod Update events (we clear captured | ||
| // on persistent error so the next event re-tries). | ||
| func (w *Watcher) runCapture(ctx context.Context, pod *corev1.Pod) { | ||
| // captured marks a pod UID as scheduled, and handlePodEvent treats a | ||
| // marked UID as nothing-to-do. Only a committed capture may keep the | ||
| // mark: every other exit here (warmup cancelled, semaphore wait | ||
| // cancelled, pod refresh failed, capture failed) has to release it or | ||
| // the pod is never retried and every later event returns silently. | ||
| // Previously only the capture-error path released it, so an abort left | ||
| // the UID poisoned for the life of the agent. | ||
| committed := false | ||
| defer func() { | ||
| if !committed { | ||
| w.captured.Delete(pod.UID) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🔴 Critical | ⚡ Quick win Keep the original UID for deferred cleanup. If Proposed fix committed := false
+ scheduledUID := pod.UID
defer func() {
if !committed {
- w.captured.Delete(pod.UID)
+ w.captured.Delete(scheduledUID)
}
}()🤖 Prompt for AI Agents |
||
| } | ||
| }() | ||
| log := w.logger().WithFields(logrus.Fields{ | ||
| "pod": pod.Namespace + "/" + pod.Name, | ||
| "pod_uid": string(pod.UID), | ||
|
|
@@ -236,13 +255,15 @@ func (w *Watcher) runCapture(ctx context.Context, pod *corev1.Pod) { | |
| select { | ||
| case <-time.After(w.WarmupDelay): | ||
| case <-ctx.Done(): | ||
| log.Debug("watcher: warmup cancelled before capture; releasing the pod for retry") | ||
| return | ||
| } | ||
| } | ||
| select { | ||
| case w.sem <- struct{}{}: | ||
| defer func() { <-w.sem }() | ||
| case <-ctx.Done(): | ||
| log.Debug("watcher: cancelled waiting for a capture slot; releasing the pod for retry") | ||
| return | ||
| } | ||
|
|
||
|
|
@@ -282,15 +303,15 @@ func (w *Watcher) runCapture(ctx context.Context, pod *corev1.Pod) { | |
| m, err := w.Capturer.Capture(captureCtx, req) | ||
| if err != nil { | ||
| log.WithError(err).Warn("capture failed; will retry on next Update event") | ||
| // Allow retry on subsequent events. | ||
| w.captured.Delete(pod.UID) | ||
| // The deferred release above re-arms the pod; no explicit Delete. | ||
| return | ||
| } | ||
| log.WithFields(logrus.Fields{ | ||
| "hash": m.Hash[:12], | ||
| "size_mib": m.TotalSizeBytes / 1024 / 1024, | ||
| "files": m.FileCount, | ||
| }).Info("capture committed for pod") | ||
| committed = true | ||
| } | ||
|
|
||
| func (w *Watcher) isLabeledForCapture(pod *corev1.Pod) bool { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Identify the NVMesh setting as a ConfigMap override.
The built-in NVMesh profile does not set
Prewarm, so it enables prewarming by default. This example disables it only when an operator installs the ConfigMap. Clarify that distinction here; the nearby text says the built-in table carries the same defaults.🤖 Prompt for AI Agents