Skip to content

Commit 4f9674e

Browse files
authored
snapshot-agent: add workload channel integration tests (#105)
Design doc: https://docs.google.com/document/d/1QPi1NzlCq2_OXmT2-_bFdGF4eSE__KzkqjHjQKGGEzQ/ ## Summary End-to-end integration tests for the app-channel backend, covering the full production chain: caller → Python client → agent → workload channel → client-library adapter → vLLM Python API. - **Channel workload pod**: vLLM embedded through its Python API (no HTTP server), registered with the agent via `register_workload()`. The client library under test and the workload script are mounted from a ConfigMap built by the harness, so pods run the exact code from the branch. Deterministic generations are driven through a file protocol; the readiness probe fires once the model is loaded and the workload registered. - **Tests** (both suites address the workload by job ID alone — no endpoints): - `VLLMChannelSleepWake` (standalone + k8s): generate → snapshot over the channel → VRAM freed → restore → identical generation. - `VLLMChannelCompound` (standalone): channel suspend, then CUDA checkpoint of the suspended process, restored in reverse order, generation intact. - `agentctl.py` grows `--backend channel`; harness gains ConfigMap/trigger helpers; runner RBAC adds `configmaps`. Two workload-pod environment notes encoded in the pod spec: the workload script must not run from the ConfigMap mount (the client package's `types.py` would shadow the stdlib module), and `protobuf` must be upgraded past the image's pin to match the client's generated code. ## Testing Full suite on H100 (image `snapshot-agent-app-aware:chan-b` = this branch + #88): **12/12 pass** — the 9 existing backend tests plus the 3 channel tests. ``` PASS TestK8s/CUDAWatcherDiscoveredPIDs, VLLMSleepWake, SGLangReleaseResume, VLLMChannelSleepWake PASS TestStandalone/CUDACheckpointRestore, VLLMSleepWake, VLLMCompound, VLLMSuspendDiscard, SGLangReleaseResume, SGLangCompound, VLLMChannelSleepWake, VLLMChannelCompound ``` Signed-off-by: Aishu Kamal <aishuk@google.com>
1 parent 20dfc7d commit 4f9674e

8 files changed

Lines changed: 339 additions & 3 deletions

File tree

tests/integration/harness/harness.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,3 +277,13 @@ func (c *Cluster) PodVRAMMiB(t *testing.T, pod, container string, timeout time.D
277277
}
278278
return mib
279279
}
280+
281+
// DeleteConfigMap deletes a ConfigMap by name in the cluster's namespace.
282+
// NotFound errors are ignored (idempotent cleanup).
283+
func (c *Cluster) DeleteConfigMap(name string) error {
284+
err := c.Client.CoreV1().ConfigMaps(c.Namespace).Delete(context.Background(), name, metav1.DeleteOptions{})
285+
if apierrors.IsNotFound(err) {
286+
return nil
287+
}
288+
return err
289+
}

tests/integration/runner.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,9 @@ rules:
6868
- apiGroups: [""]
6969
resources: ["pods/exec"]
7070
verbs: ["create"]
71+
- apiGroups: [""]
72+
resources: ["configmaps"]
73+
verbs: ["get", "create", "delete"]
7174
- apiGroups: [""]
7275
resources: ["pods/log"]
7376
verbs: ["get"]

tests/integration/snapshot-agent/agentctl.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,11 @@
77
constructed here, in Python, the same way a real workload would build them.
88
99
Usage:
10-
agentctl.py --agent HOST:PORT snapshot|restore --job-id ID --backend cuda|app
10+
agentctl.py --agent HOST:PORT snapshot|restore --job-id ID
11+
--backend cuda|app|channel
1112
[--pids 1,2,3] (cuda)
1213
[--app vllm|sglang] [--endpoints URL,URL]
13-
[--mode offload|discard] [--tags a,b] (app)
14+
[--mode offload|discard] [--tags a,b] (app, channel)
1415
1516
Exits 0 when the operation completes, 1 otherwise.
1617
"""
@@ -51,6 +52,12 @@ def build_config(args: argparse.Namespace) -> snapshot_agent_pb2.BackendConfig:
5152
app_endpoint.tags.extend(args.tags.split(","))
5253
return snapshot_agent_pb2.BackendConfig(app_endpoint=app_endpoint)
5354

55+
if args.backend == "channel":
56+
app_channel = snapshot_agent_pb2.AppChannelConfig(mode=MODES[args.mode])
57+
if args.tags:
58+
app_channel.tags.extend(args.tags.split(","))
59+
return snapshot_agent_pb2.BackendConfig(app_channel=app_channel)
60+
5461
raise ValueError(f"unknown backend {args.backend!r}")
5562

5663

@@ -60,7 +67,7 @@ def main() -> int:
6067
parser.add_argument("--agent", required=True, help="agent endpoint HOST:PORT")
6168
parser.add_argument("--job-id", required=True)
6269
parser.add_argument("--group", default="test")
63-
parser.add_argument("--backend", required=True, choices=["cuda", "app"])
70+
parser.add_argument("--backend", required=True, choices=["cuda", "app", "channel"])
6471
parser.add_argument("--pids", default="", help="comma-separated PIDs (cuda)")
6572
parser.add_argument("--app", default="", choices=["", "vllm", "sglang"], help="application (app backend)")
6673
parser.add_argument("--endpoints", default="", help="comma-separated application URLs")
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
#!/usr/bin/env python3
2+
"""In-pod test workload for the app-channel backend.
3+
4+
Embeds vLLM through its Python API (no HTTP server) and registers with the
5+
node's snapshot-agent over the workload channel — the integration path for
6+
Python-API workloads such as RL samplers. The harness drives deterministic
7+
generations through a file protocol:
8+
9+
touch STATE/trigger -> completion text written to STATE/result
10+
11+
STATE/ready is created once the model is loaded and the workload is
12+
registered; the pod's readiness probe watches it.
13+
"""
14+
15+
import os
16+
import time
17+
from pathlib import Path
18+
19+
from timeslice.snapshot_agent import register_workload
20+
from vllm import LLM, SamplingParams
21+
22+
STATE = Path("/workload-state")
23+
PROMPT = "The capital of France is"
24+
25+
26+
def main() -> None:
27+
STATE.mkdir(exist_ok=True)
28+
llm = LLM(
29+
model=os.environ["MODEL"],
30+
enable_sleep_mode=True,
31+
gpu_memory_utilization=0.5,
32+
)
33+
params = SamplingParams(temperature=0, max_tokens=15)
34+
35+
job_id = os.environ["TIME_SLICE_JOB_ID"]
36+
handle = register_workload(
37+
os.environ["SNAPSHOT_AGENT_ADDR"],
38+
job_id=job_id,
39+
group=os.environ.get("TIME_SLICE_GROUP", "test"),
40+
workload=llm,
41+
)
42+
print(f"registered workload for job {job_id}", flush=True)
43+
44+
(STATE / "ready").write_text("ok")
45+
trigger = STATE / "trigger"
46+
result = STATE / "result"
47+
try:
48+
while True:
49+
if trigger.exists():
50+
outputs = llm.generate([PROMPT], params)
51+
result.write_text(outputs[0].outputs[0].text)
52+
trigger.unlink()
53+
time.sleep(0.5)
54+
finally:
55+
handle.close()
56+
57+
58+
if __name__ == "__main__":
59+
main()

tests/integration/snapshot-agent/engines.go

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
package integration
44

55
import (
6+
"fmt"
7+
68
corev1 "k8s.io/api/core/v1"
79
"k8s.io/apimachinery/pkg/api/resource"
810
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -161,3 +163,71 @@ func gpuTolerations() []corev1.Toleration {
161163
Effect: corev1.TaintEffectNoSchedule,
162164
}}
163165
}
166+
167+
// channelWorkloadPod runs vLLM through its Python API (no HTTP server) and
168+
// registers with the agent over the workload channel. The client library and
169+
// the workload script are mounted from a ConfigMap built by the harness; the
170+
// readiness probe fires once the model is loaded and the workload registered.
171+
func channelWorkloadPod(h *Harness, jobID string) *corev1.Pod {
172+
labels := map[string]string{
173+
"app": channelPodName,
174+
"test-suite": "snapshot-agent-integration",
175+
}
176+
if h.Mode == "k8s" {
177+
labels["timeslice.io/job-id"] = jobID
178+
labels["timeslice.io/group"] = "test"
179+
}
180+
// The script must not run from /opt/src: the client package's types.py
181+
// would shadow the stdlib types module via the script-dir sys.path entry.
182+
startup := "pip install -q --upgrade grpcio protobuf && " +
183+
"mkdir -p /opt/pkg/timeslice/snapshot_agent && " +
184+
"cp /opt/src/*.py /opt/pkg/timeslice/snapshot_agent/ && " +
185+
": > /opt/pkg/timeslice/__init__.py && " +
186+
"cp /opt/src/channel_workload.py /opt/channel_workload.py && " +
187+
"PYTHONPATH=/opt/pkg exec python3 /opt/channel_workload.py"
188+
return &corev1.Pod{
189+
ObjectMeta: metav1.ObjectMeta{
190+
Name: channelPodName,
191+
Namespace: namespace,
192+
Labels: labels,
193+
},
194+
Spec: corev1.PodSpec{
195+
ServiceAccountName: "snapshot-agent-test",
196+
RestartPolicy: corev1.RestartPolicyNever,
197+
NodeName: h.Node,
198+
Tolerations: gpuTolerations(),
199+
Volumes: []corev1.Volume{{
200+
Name: "src",
201+
VolumeSource: corev1.VolumeSource{
202+
ConfigMap: &corev1.ConfigMapVolumeSource{
203+
LocalObjectReference: corev1.LocalObjectReference{Name: channelConfigMapName},
204+
},
205+
},
206+
}},
207+
Containers: []corev1.Container{{
208+
Name: channelContainer,
209+
Image: "vllm/vllm-openai:v0.25.1@sha256:e4f88a835143cd22aee2397a26ec6bb80b3a4a6fe0c882bcbc63822904766089",
210+
Command: []string{"sh", "-c", startup},
211+
Env: []corev1.EnvVar{
212+
{Name: "MODEL", Value: h.Model},
213+
{Name: "SNAPSHOT_AGENT_ADDR", Value: fmt.Sprintf("%s:%d", h.AgentIP, h.AgentPort)},
214+
{Name: "TIME_SLICE_JOB_ID", Value: jobID},
215+
{Name: "TIME_SLICE_GROUP", Value: "test"},
216+
},
217+
VolumeMounts: []corev1.VolumeMount{{Name: "src", MountPath: "/opt/src"}},
218+
ReadinessProbe: &corev1.Probe{
219+
ProbeHandler: corev1.ProbeHandler{
220+
Exec: &corev1.ExecAction{Command: []string{"test", "-f", "/workload-state/ready"}},
221+
},
222+
InitialDelaySeconds: 20,
223+
PeriodSeconds: 5,
224+
FailureThreshold: 60,
225+
},
226+
Resources: corev1.ResourceRequirements{
227+
Limits: corev1.ResourceList{"nvidia.com/gpu": resource.MustParse("1")},
228+
Requests: corev1.ResourceList{"nvidia.com/gpu": resource.MustParse("1")},
229+
},
230+
}},
231+
},
232+
}
233+
}

tests/integration/snapshot-agent/harness.go

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,13 @@ import (
2929
"net/http"
3030
"os"
3131
"os/exec"
32+
"path/filepath"
3233
"strconv"
3334
"strings"
3435
"testing"
3536
"time"
3637

38+
corev1 "k8s.io/api/core/v1"
3739
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
3840

3941
"github.com/llm-d-incubation/llm-d-rl-time-slicing/tests/integration/harness"
@@ -53,6 +55,15 @@ const (
5355
opTimeout = 120 * time.Second
5456
// vramFreedMiB is the threshold below which we consider GPU memory freed.
5557
vramFreedMiB = 5000
58+
59+
// Channel workload pod pieces (see channelWorkloadPod and
60+
// WithChannelWorkload).
61+
channelPodName = "channel-workload-test"
62+
channelConfigMapName = "channel-workload-src"
63+
channelContainer = "workload"
64+
// channelClientSrcDir is the Python client package, relative to this
65+
// package (go test's working directory), mounted into the workload pod.
66+
channelClientSrcDir = "../../../pkg/client/python/timeslice/snapshot_agent"
5667
)
5768

5869
// Harness manages the test stack for one deployment mode.
@@ -301,6 +312,16 @@ func appConfig(app, endpoint, mode string) BackendArgs {
301312
return args
302313
}
303314

315+
// channelConfig targets the workload registered on the job's channel.
316+
// mode may be "" (workload's registered default), "offload", or "discard".
317+
func channelConfig(mode string) BackendArgs {
318+
args := BackendArgs{"--backend", "channel"}
319+
if mode != "" {
320+
args = append(args, "--mode", mode)
321+
}
322+
return args
323+
}
324+
304325
// SnapshotOK snapshots via the Python client and fails the test if the
305326
// operation does not complete.
306327
func (h *Harness) SnapshotOK(t *testing.T, jobID string, cfg BackendArgs) {
@@ -381,3 +402,126 @@ func RequireFreedAndCorrect(t *testing.T, vramWhileAsleep int, before, after str
381402
t.Errorf("inference changed after restore: before=%q after=%q", before, after)
382403
}
383404
}
405+
406+
407+
// --- Channel workload helpers ---
408+
409+
// ChannelWorkload is a running Python-API workload registered with the agent
410+
// over the workload channel.
411+
type ChannelWorkload struct {
412+
PodName string
413+
JobID string
414+
PID int32 // standalone mode only
415+
}
416+
417+
// WithChannelWorkload deploys the channel workload pod (vLLM via the Python
418+
// API, registered through the client library), waits until it is registered,
419+
// runs fn, and deletes the pod (freeing the GPU).
420+
func (h *Harness) WithChannelWorkload(t *testing.T, fn func(t *testing.T, w *ChannelWorkload)) {
421+
t.Helper()
422+
jobID := "chan-standalone"
423+
if h.Mode == "k8s" {
424+
jobID = "chan-k8s"
425+
}
426+
427+
h.createChannelSourceConfigMap(t)
428+
defer func() {
429+
if err := h.DeleteConfigMap(channelConfigMapName); err != nil {
430+
t.Logf("warning: failed to delete ConfigMap %s: %v", channelConfigMapName, err)
431+
}
432+
}()
433+
434+
h.DeletePodAndWait(t, channelPodName)
435+
pod := channelWorkloadPod(h, jobID)
436+
if _, err := h.Client.CoreV1().Pods(namespace).Create(context.Background(), pod, metav1.CreateOptions{}); err != nil {
437+
t.Fatalf("creating channel workload pod: %v", err)
438+
}
439+
defer h.DeletePodAndWait(t, channelPodName)
440+
441+
// The readiness probe covers model load and channel registration.
442+
h.WaitPodReady(t, channelPodName, podTimeout)
443+
t.Logf("channel workload ready, registered as job %s", jobID)
444+
445+
w := &ChannelWorkload{PodName: channelPodName, JobID: jobID}
446+
if h.Mode == "standalone" {
447+
w.PID = h.findPID(t, "channel_workload")
448+
t.Logf("channel workload PID: %d", w.PID)
449+
} else {
450+
t.Log("waiting 10s for watcher to register the job...")
451+
time.Sleep(10 * time.Second)
452+
}
453+
454+
fn(t, w)
455+
}
456+
457+
// createChannelSourceConfigMap packages the Python client library and the
458+
// workload script into a ConfigMap mounted by the workload pod, so the pod
459+
// runs the exact client code under test.
460+
func (h *Harness) createChannelSourceConfigMap(t *testing.T) {
461+
t.Helper()
462+
files := map[string]string{}
463+
entries, err := os.ReadDir(channelClientSrcDir)
464+
if err != nil {
465+
t.Fatalf("reading client source dir: %v", err)
466+
}
467+
for _, entry := range entries {
468+
if !strings.HasSuffix(entry.Name(), ".py") {
469+
continue
470+
}
471+
data, err := os.ReadFile(filepath.Join(channelClientSrcDir, entry.Name()))
472+
if err != nil {
473+
t.Fatalf("reading %s: %v", entry.Name(), err)
474+
}
475+
files[entry.Name()] = string(data)
476+
}
477+
script, err := os.ReadFile("channel_workload.py")
478+
if err != nil {
479+
t.Fatalf("reading channel_workload.py: %v", err)
480+
}
481+
files["channel_workload.py"] = string(script)
482+
483+
if err := h.DeleteConfigMap(channelConfigMapName); err != nil {
484+
t.Logf("warning: pre-create ConfigMap cleanup failed: %v", err)
485+
}
486+
cm := &corev1.ConfigMap{
487+
ObjectMeta: metav1.ObjectMeta{
488+
Name: channelConfigMapName,
489+
Namespace: namespace,
490+
Labels: map[string]string{"test-suite": "snapshot-agent-integration"},
491+
},
492+
Data: files,
493+
}
494+
if _, err := h.Client.CoreV1().ConfigMaps(namespace).Create(context.Background(), cm, metav1.CreateOptions{}); err != nil {
495+
t.Fatalf("creating ConfigMap %s: %v", channelConfigMapName, err)
496+
}
497+
}
498+
499+
500+
// TriggerGenerate asks the workload for a deterministic generation through
501+
// its file protocol and returns the completion text.
502+
func (h *Harness) TriggerGenerate(t *testing.T, w *ChannelWorkload) string {
503+
t.Helper()
504+
_, err := h.ExecPod(w.PodName, channelContainer, opTimeout, "sh", "-c",
505+
"rm -f /workload-state/result && touch /workload-state/trigger")
506+
if err != nil {
507+
t.Fatalf("triggering generation on %s: %v", w.PodName, err)
508+
}
509+
deadline := time.Now().Add(2 * time.Minute)
510+
for time.Now().Before(deadline) {
511+
out, err := h.ExecPod(w.PodName, channelContainer, opTimeout, "sh", "-c",
512+
"cat /workload-state/result 2>/dev/null")
513+
if err == nil && out != "" {
514+
return out
515+
}
516+
time.Sleep(2 * time.Second)
517+
}
518+
t.Fatalf("timeout waiting for generation from %s", w.PodName)
519+
return ""
520+
}
521+
522+
// WorkloadVRAMMiB returns the GPU memory used (MiB) as seen from the channel
523+
// workload pod.
524+
func (h *Harness) WorkloadVRAMMiB(t *testing.T, w *ChannelWorkload) int {
525+
t.Helper()
526+
return h.PodVRAMMiB(t, w.PodName, channelContainer, opTimeout)
527+
}

tests/integration/snapshot-agent/k8s_test.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,4 +57,18 @@ func TestK8s(t *testing.T) {
5757
RequireFreedAndCorrect(t, vramReleased, before, after)
5858
})
5959
})
60+
61+
// The channel workload's pod carries the job label the watcher discovers;
62+
// the workload registers over the channel under the same job ID.
63+
h.WithChannelWorkload(t, func(t *testing.T, w *ChannelWorkload) {
64+
t.Run("VLLMChannelSleepWake", func(t *testing.T) {
65+
before := h.TriggerGenerate(t, w)
66+
h.SnapshotOK(t, w.JobID, channelConfig(""))
67+
vramAsleep := h.WorkloadVRAMMiB(t, w)
68+
t.Logf("VRAM after channel snapshot: %d MiB", vramAsleep)
69+
h.RestoreOK(t, w.JobID, channelConfig(""))
70+
after := h.TriggerGenerate(t, w)
71+
RequireFreedAndCorrect(t, vramAsleep, before, after)
72+
})
73+
})
6074
}

0 commit comments

Comments
 (0)