Skip to content

Commit 18545f6

Browse files
committed
Refactored tests, added encoding and leadership tests
Signed-off-by: Shmuel Kallner <kallner@il.ibm.com>
1 parent 2c8cc55 commit 18545f6

3 files changed

Lines changed: 264 additions & 9 deletions

File tree

test/e2e/e2e_suite_test.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
apiextv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
1616
"k8s.io/apimachinery/pkg/api/errors"
1717
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
18+
"k8s.io/apimachinery/pkg/types"
1819
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
1920
"sigs.k8s.io/controller-runtime/pkg/client/config"
2021
k8slog "sigs.k8s.io/controller-runtime/pkg/log"
@@ -28,6 +29,8 @@ import (
2829
const (
2930
// kindClusterName is the name of the Kind cluster created for e2e tests.
3031
kindClusterName = "e2e-tests"
32+
// eppName is the value of the app label on the EPP pods
33+
eppName = "e2e-epp"
3134
// defaultReadyTimeout is the default timeout for a resource to report a ready state.
3235
defaultReadyTimeout = 3 * time.Minute
3336
// defaultInterval is the default interval to check if a resource exists or ready conditions.
@@ -281,6 +284,12 @@ func setupNameSpace() {
281284
_, err = testConfig.KubeCli.CoreV1().Namespaces().Create(testConfig.Context, namespace, metav1.CreateOptions{})
282285
gomega.Expect(err).NotTo(gomega.HaveOccurred())
283286
createdNameSpace = true
287+
288+
ginkgo.By("Ensuring namespace exists: " + nsName)
289+
testutils.EventuallyExists(testConfig, func() error {
290+
return testConfig.K8sClient.Get(testConfig.Context,
291+
types.NamespacedName{Name: nsName}, &corev1.Namespace{})
292+
})
284293
}
285294

286295
// createCRDs creates the Inference Extension CRDs used for testing.

test/e2e/e2e_test.go

Lines changed: 105 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,13 @@ package e2e
22

33
import (
44
"fmt"
5+
"strings"
56
"time"
67

78
"github.com/onsi/ginkgo/v2"
89
"github.com/onsi/gomega"
10+
corev1 "k8s.io/api/core/v1"
11+
"sigs.k8s.io/controller-runtime/pkg/client"
912

1013
"github.com/llm-d/llm-d-router/pkg/epp/framework/plugins/scheduling/profilehandler/disagg"
1114
testutils "github.com/llm-d/llm-d-router/test/utils"
@@ -49,6 +52,9 @@ var (
4952
prefillDecodeSelector = map[string]string{"llm-d.ai/role": "prefill-decode"}
5053
encodeSelector = map[string]string{"llm-d.ai/role": "encode"}
5154
epdSingleSelector = map[string]string{"llm-d.ai/role": "encode-prefill-decode"}
55+
56+
singleEmbedding = []string{"The food was delicious and the service was great."}
57+
doubleEmbedding = []string{"First sentence to embed.", "Second sentence to embed."}
5258
)
5359

5460
var _ = ginkgo.Describe("Run end to end tests", ginkgo.Ordered, func() {
@@ -60,23 +66,89 @@ var _ = ginkgo.Describe("Run end to end tests", ginkgo.Ordered, func() {
6066

6167
epp := createEndPointPicker(simpleConfig)
6268

63-
prefillPods, decodePods := getModelServerPods(podSelector, prefillSelector, decodeSelector)
64-
gomega.Expect(prefillPods).Should(gomega.BeEmpty())
65-
gomega.Expect(decodePods).Should(gomega.HaveLen(1))
69+
generateAndCheckLoad(5)
6670

67-
nsHdr, podHdr, _ := runCompletion(simplePrompt, simModelName)
68-
gomega.Expect(nsHdr).Should(gomega.Equal(nsName))
69-
gomega.Expect(podHdr).Should(gomega.Equal(decodePods[0]))
71+
testutils.DeleteObjects(testConfig, epp)
72+
testutils.DeleteObjects(testConfig, modelServers)
73+
})
7074

71-
nsHdr, podHdr, _ = runChatCompletion(simplePrompt, simModelName)
72-
gomega.Expect(nsHdr).Should(gomega.Equal(nsName))
73-
gomega.Expect(podHdr).Should(gomega.Equal(decodePods[0]))
75+
ginkgo.It("should report metrics", func() {
76+
numTargetPorts := 1
77+
infPoolObjects = createInferencePool(numTargetPorts, true)
78+
temp := strings.Split(infPoolObjects[0], "/")
79+
infPoolName := temp[1]
80+
81+
modelServers := createModelServersDecode(1)
82+
83+
epp := createEndPointPicker(simpleConfig)
84+
85+
verifyMetrics(infPoolName, numTargetPorts)
7486

7587
testutils.DeleteObjects(testConfig, epp)
7688
testutils.DeleteObjects(testConfig, modelServers)
7789
})
7890
})
7991

92+
ginkgo.When("Running leader election", func() {
93+
ginkgo.It("Should elect one leader and have other pods as not ready", func() {
94+
numOfPods := 3
95+
numTargetPorts := 1
96+
97+
infPoolObjects = createInferencePool(numTargetPorts, true)
98+
99+
modelServers := createModelServersDecode(1)
100+
101+
epp := createEndPointPickerHelper(simpleConfig, numOfPods, true, false)
102+
103+
ginkgo.By("Verifying that exactly one EPP pod is ready")
104+
waitForReadyLeader(numOfPods)
105+
106+
testutils.DeleteObjects(testConfig, epp)
107+
testutils.DeleteObjects(testConfig, modelServers)
108+
})
109+
110+
ginkgo.It("Should successfully failover and serve traffic after the leader pod is deleted", func() {
111+
numOfPods := 3
112+
numTargetPorts := 1
113+
114+
infPoolObjects = createInferencePool(numTargetPorts, true)
115+
temp := strings.Split(infPoolObjects[0], "/")
116+
infPoolName := temp[1]
117+
118+
modelServers := createModelServersDecode(1)
119+
120+
epp := createEndPointPickerHelper(simpleConfig, numOfPods, true, false)
121+
122+
ginkgo.By("STEP 1: Verifying initial leader is working correctly before failover")
123+
leaderPod := waitForReadyLeader(numOfPods)
124+
generateAndCheckLoad(5)
125+
verifyMetrics(infPoolName, numTargetPorts)
126+
127+
ginkgo.By("Found initial leader pod: " + leaderPod.Name)
128+
129+
ginkgo.By(fmt.Sprintf("Deleting leader pod %s to trigger failover", leaderPod.Name))
130+
gomega.Expect(testConfig.K8sClient.Delete(testConfig.Context, leaderPod)).To(gomega.Succeed())
131+
132+
ginkgo.By("STEP 3: Waiting for a new and different leader to be elected")
133+
// The deployment controller will create a new pod. We need to wait for the total number of pods
134+
// to be back to 3, and for one of the other pods to become the new leader.
135+
var newLeaderPod *corev1.Pod
136+
gomega.Eventually(func(g gomega.Gomega) {
137+
newLeaderPod = waitForReadyLeader(numOfPods)
138+
g.Expect(newLeaderPod.Name).NotTo(gomega.Equal(leaderPod.Name), "The new leader should not be the same as the old deleted leader")
139+
}, testConfig.ReadyTimeout, testConfig.Interval).Should(gomega.Succeed())
140+
ginkgo.By("Found new leader pod: " + newLeaderPod.Name)
141+
142+
ginkgo.By("STEP 4: Verifying the new leader is working correctly after failover")
143+
generateAndCheckLoad(5)
144+
verifyMetrics(infPoolName, numTargetPorts)
145+
146+
testutils.DeleteObjects(testConfig, epp)
147+
testutils.DeleteObjects(testConfig, modelServers)
148+
149+
})
150+
})
151+
80152
ginkgo.When("Running a PD configuration with nixlv2 connector(deprecated pd-profile-handler)", ginkgo.Label(metricsTestLabel, deprecatedPDTestLabel), func() {
81153
ginkgo.It("should run successfully", func() {
82154
infPoolObjects = createInferencePool(1, true)
@@ -862,3 +934,27 @@ var _ = ginkgo.Describe("Run end to end tests", ginkgo.Ordered, func() {
862934
})
863935
})
864936
})
937+
938+
func waitForReadyLeader(numOfPods int) *corev1.Pod {
939+
var leaderPod *corev1.Pod
940+
gomega.Eventually(func(g gomega.Gomega) {
941+
podList := &corev1.PodList{}
942+
err := testConfig.K8sClient.List(testConfig.Context, podList, client.InNamespace(testConfig.NsName), client.MatchingLabels{"app": eppName})
943+
g.Expect(err).NotTo(gomega.HaveOccurred())
944+
945+
// The deployment should have 3 replicas for leader election.
946+
g.Expect(podList.Items).To(gomega.HaveLen(numOfPods))
947+
948+
readyPods := 0
949+
for _, pod := range podList.Items {
950+
for _, cond := range pod.Status.Conditions {
951+
if cond.Type == corev1.PodReady && cond.Status == corev1.ConditionTrue {
952+
readyPods++
953+
leaderPod = &pod
954+
}
955+
}
956+
}
957+
g.Expect(readyPods).To(gomega.Equal(1), "Expected exactly one pod to be ready")
958+
}, testConfig.ReadyTimeout, testConfig.Interval).Should(gomega.Succeed())
959+
return leaderPod
960+
}

test/e2e/requests_test.go

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"github.com/onsi/gomega"
1212
"github.com/openai/openai-go"
1313
"github.com/openai/openai-go/option"
14+
"github.com/openai/openai-go/packages/param"
1415
)
1516

1617
func newOpenAIClient() *openai.Client {
@@ -24,6 +25,30 @@ func extractInferenceHeaders(httpResp *http.Response) (string, string, string) {
2425
httpResp.Header.Get("x-inference-port")
2526
}
2627

28+
func generateAndCheckLoad(count int) {
29+
for range count {
30+
prefillPods, decodePods := getModelServerPods(podSelector, prefillSelector, decodeSelector)
31+
gomega.Expect(prefillPods).Should(gomega.BeEmpty())
32+
gomega.Expect(decodePods).Should(gomega.HaveLen(1))
33+
34+
nsHdr, podHdr, _ := runCompletion(simplePrompt, simModelName)
35+
gomega.Expect(nsHdr).Should(gomega.Equal(nsName))
36+
gomega.Expect(podHdr).Should(gomega.Equal(decodePods[0]))
37+
38+
nsHdr, podHdr, _ = runChatCompletion(simplePrompt, simModelName)
39+
gomega.Expect(nsHdr).Should(gomega.Equal(nsName))
40+
gomega.Expect(podHdr).Should(gomega.Equal(decodePods[0]))
41+
42+
nsHdr, podHdr, _ = runEmbeddings(singleEmbedding, simModelName)
43+
gomega.Expect(nsHdr).Should(gomega.Equal(nsName))
44+
gomega.Expect(podHdr).Should(gomega.Equal(decodePods[0]))
45+
46+
nsHdr, podHdr, _ = runEmbeddings(doubleEmbedding, simModelName)
47+
gomega.Expect(nsHdr).Should(gomega.Equal(nsName))
48+
gomega.Expect(podHdr).Should(gomega.Equal(decodePods[0]))
49+
}
50+
}
51+
2752
// doPost sends a POST request with a JSON body to the given path, asserts HTTP 200,
2853
// and returns the x-inference-namespace, x-inference-pod headers and the response body.
2954
func doPost(path, body string, extraHeaders map[string]string) (string, string, []byte) {
@@ -46,6 +71,27 @@ func doPost(path, body string, extraHeaders map[string]string) (string, string,
4671
return resp.Header.Get("x-inference-namespace"), resp.Header.Get("x-inference-pod"), respBody
4772
}
4873

74+
// doPostWithError sends a POST request with a JSON body to the given path
75+
// and returns the status code and the response body.
76+
func doPostWithError(path, body string, extraHeaders map[string]string) (int, []byte) {
77+
req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:%s%s", port, path), strings.NewReader(body))
78+
gomega.Expect(err).ShouldNot(gomega.HaveOccurred())
79+
req.Header.Set("Content-Type", "application/json")
80+
for k, v := range extraHeaders {
81+
req.Header.Set(k, v)
82+
}
83+
resp, err := http.DefaultClient.Do(req)
84+
gomega.Expect(err).ShouldNot(gomega.HaveOccurred())
85+
defer func() {
86+
gomega.Expect(resp.Body.Close()).ToNot(gomega.HaveOccurred())
87+
}()
88+
89+
respBody, err := io.ReadAll(resp.Body)
90+
gomega.Expect(err).ShouldNot(gomega.HaveOccurred())
91+
92+
return resp.StatusCode, respBody
93+
}
94+
4995
func runCompletion(prompt string, theModel openai.CompletionNewParamsModel) (string, string, string) {
5096
var httpResp *http.Response
5197

@@ -115,6 +161,27 @@ func runChatCompletion(prompt, modelName string) (string, string, string) {
115161
return extractInferenceHeaders(httpResp)
116162
}
117163

164+
func runEmbeddings(embeddings []string, modelName string) (string, string, string) {
165+
var httpResp *http.Response
166+
167+
input := openai.EmbeddingNewParamsInputUnion{}
168+
if len(embeddings) == 1 {
169+
input.OfString = param.NewOpt(embeddings[0])
170+
} else {
171+
input.OfArrayOfStrings = embeddings
172+
}
173+
174+
params := openai.EmbeddingNewParams{
175+
Input: input,
176+
Model: modelName,
177+
}
178+
resp, err := newOpenAIClient().Embeddings.New(testConfig.Context, params, option.WithResponseInto(&httpResp))
179+
gomega.Expect(err).ShouldNot(gomega.HaveOccurred())
180+
gomega.Expect(resp.Data).Should(gomega.HaveLen(len(embeddings)))
181+
182+
return extractInferenceHeaders(httpResp)
183+
}
184+
118185
// runRawChatCompletion POSTs the given JSON body to /v1/chat/completions and returns
119186
// the x-inference-namespace and x-inference-pod response headers.
120187
func runRawChatCompletion(body string) (string, string) {
@@ -217,3 +284,86 @@ func cacheThresholdHeaders(force bool) map[string]string {
217284
}
218285
return nil
219286
}
287+
288+
func verifyMetrics(infPoolName string, numTargetPorts int) {
289+
290+
generateAndCheckLoad(10)
291+
292+
// Send a few errors
293+
for range 10 {
294+
doPostWithError("/v1/chat/completions", "an invalid body", nil)
295+
}
296+
297+
metricsURL := fmt.Sprintf("http://localhost:%s/metrics", metricsPort)
298+
299+
if k8sContext != "" {
300+
// Use port-forward to access the EPP pod's metrics endpoint.
301+
startEPPMetricsPortForward()
302+
}
303+
304+
theMetrics := getMetrics(metricsURL)
305+
gomega.Expect(theMetrics).ShouldNot(gomega.BeEmpty())
306+
metricsAsString := strings.Join(theMetrics, "\n")
307+
308+
_, decodePods := getModelServerPods(podSelector, prefillSelector, decodeSelector)
309+
310+
// Define the metrics we expect to see
311+
preset := []string{ //nolint:prealloc
312+
"inference_objective_request_total",
313+
"inference_objective_request_error_total",
314+
"inference_objective_request_duration_seconds",
315+
"inference_objective_normalized_time_per_output_token_seconds",
316+
"inference_objective_request_sizes",
317+
"inference_objective_response_sizes",
318+
"inference_objective_input_tokens",
319+
"inference_objective_output_tokens",
320+
"inference_pool_average_kv_cache_utilization",
321+
"inference_pool_average_queue_size",
322+
"inference_pool_per_pod_queue_size",
323+
"inference_objective_running_requests",
324+
"inference_pool_ready_pods",
325+
"inference_extension_info",
326+
327+
// llm_d metrics
328+
"llm_d_epp_request_total",
329+
"llm_d_epp_request_error_total",
330+
"llm_d_epp_request_duration_seconds",
331+
"llm_d_epp_normalized_time_per_output_token_seconds",
332+
"llm_d_epp_request_sizes",
333+
"llm_d_epp_response_sizes",
334+
"llm_d_epp_input_tokens",
335+
"llm_d_epp_output_tokens",
336+
"llm_d_epp_average_kv_cache_utilization",
337+
"llm_d_epp_average_queue_size",
338+
"llm_d_epp_per_endpoint_queue_size",
339+
"llm_d_epp_running_requests",
340+
"llm_d_epp_ready_endpoints",
341+
"llm_d_epp_info",
342+
}
343+
expectedMetrics := make([]string, 0, len(preset)+len(decodePods)*numTargetPorts*2)
344+
expectedMetrics = append(expectedMetrics, preset...)
345+
346+
for _, modelServerPodName := range decodePods {
347+
for rank := range numTargetPorts {
348+
metricQueueSize := fmt.Sprintf(
349+
"inference_pool_per_pod_queue_size{model_server_pod=\"%s-rank-%d\",name=\"%s\"}",
350+
modelServerPodName,
351+
rank,
352+
infPoolName)
353+
expectedMetrics = append(expectedMetrics, metricQueueSize)
354+
355+
metricQueueSizeNew := fmt.Sprintf(
356+
"llm_d_epp_per_endpoint_queue_size{model_server_endpoint=\"%s-rank-%d\",name=\"%s\"}",
357+
modelServerPodName,
358+
rank,
359+
infPoolName,
360+
)
361+
expectedMetrics = append(expectedMetrics, metricQueueSizeNew)
362+
}
363+
}
364+
365+
// Check if all expected metrics are present in the metrics output.
366+
for _, metric := range expectedMetrics {
367+
gomega.Expect(metricsAsString).Should(gomega.ContainSubstring(metric))
368+
}
369+
}

0 commit comments

Comments
 (0)