Skip to content

Commit 236b67f

Browse files
authored
Merge branch 'main' into fix/fc-saturation-staleness
2 parents c769ae9 + 3a31761 commit 236b67f

27 files changed

Lines changed: 1284 additions & 41 deletions

docs/metrics.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,15 @@ Label `{name}` (the pool name).
110110
| `scheduler_e2e_duration_seconds` | Histogram | End-to-end scheduling latency. |
111111
| `scheduler_attempts_total` | Counter | Scheduling attempts; labels `{status, target_model_name, endpoint_name, namespace, port}`. |
112112

113+
### EPP processing overhead
114+
115+
Unlabelled.
116+
117+
| Name | Type | Notes |
118+
|---|---|---|
119+
| `request_processing_duration_seconds` | Histogram | Time from request receipt until the request body has been handled. Includes admission control, so under the flow control feature gate this covers queue wait; `flow_control_request_queue_duration_seconds` separates it out. |
120+
| `response_processing_duration_seconds` | Histogram | Sum of the per-chunk handler slices for a streamed response, so model-server generation time between chunks is excluded. For a non-streaming response, the interval from response headers to completion. |
121+
113122
### Plugin, info, and model rewrite
114123

115124
| Name | Type | Notes |

pkg/epp/flowcontrol/controller/controller.go

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -378,8 +378,15 @@ func (fc *FlowController) tryDistribution(
378378
return item, finalErr
379379
}
380380

381+
func finalizeOnControllerShutdown(item *internal.FlowItem) (types.QueueOutcome, error) {
382+
item.Finalize(types.ErrFlowControllerNotRunning)
383+
384+
finalState := item.FinalState()
385+
return finalState.Outcome, finalState.Err
386+
}
387+
381388
// awaitFinalization blocks until an item is finalized, either by the processor (synchronously) or by the controller
382-
// itself due to context expiry (asynchronously).
389+
// itself due to context expiry or shutdown (asynchronously).
383390
func (fc *FlowController) awaitFinalization(
384391
reqCtx context.Context,
385392
item *internal.FlowItem,
@@ -388,13 +395,20 @@ func (fc *FlowController) awaitFinalization(
388395
case <-reqCtx.Done():
389396
// Asynchronous Finalization (Controller-initiated):
390397
// The request Context expired (Cancellation/TTL) while the item was being processed.
398+
if fc.parentCtx.Err() != nil {
399+
return finalizeOnControllerShutdown(item)
400+
}
401+
391402
cause := context.Cause(reqCtx)
392403
item.Finalize(cause)
393404

394405
// The processor will eventually discard this "zombie" item during its cleanup sweep.
395406
finalState := item.FinalState()
396407
return finalState.Outcome, finalState.Err
397408

409+
case <-fc.parentCtx.Done():
410+
return finalizeOnControllerShutdown(item)
411+
398412
case finalState := <-item.Done():
399413
// Synchronous Finalization (Processor-initiated):
400414
// The processor finalized the item (Dispatch, Reject, Shutdown).

pkg/epp/flowcontrol/controller/controller_test.go

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,71 @@ func TestFlowController_EnqueueAndWait(t *testing.T) {
367367
assert.Equal(t, types.QueueOutcomeRejectedOther, outcome,
368368
"outcome should be QueueOutcomeRejectedOther on shutdown")
369369
})
370+
t.Run("OnControllerShutdownDuringFinalization", func(t *testing.T) {
371+
t.Parallel()
372+
ctx, cancel := context.WithCancel(t.Context())
373+
h := newUnitHarness(ctx, t, &Config{}, nil, nil)
374+
item := internal.NewItem(newTestRequest(defaultFlowKey), 0, time.Now())
375+
376+
result := make(chan struct {
377+
outcome types.QueueOutcome
378+
err error
379+
}, 1)
380+
go func() {
381+
outcome, err := h.fc.awaitFinalization(context.Background(), item)
382+
result <- struct {
383+
outcome types.QueueOutcome
384+
err error
385+
}{outcome: outcome, err: err}
386+
}()
387+
388+
cancel()
389+
select {
390+
case r := <-result:
391+
require.Error(t, r.err, "awaitFinalization must fail when controller shuts down")
392+
assert.ErrorIs(t, r.err, types.ErrFlowControllerNotRunning,
393+
"error should wrap ErrFlowControllerNotRunning")
394+
assert.Equal(t, types.QueueOutcomeRejectedOther, r.outcome,
395+
"outcome should be QueueOutcomeRejectedOther on shutdown")
396+
case <-time.After(time.Second):
397+
t.Fatal("awaitFinalization did not return after controller shutdown")
398+
}
399+
})
400+
t.Run("OnControllerShutdownTakesPrecedenceOverRequestCancellation", func(t *testing.T) {
401+
t.Parallel()
402+
ctx, cancel := context.WithCancel(t.Context())
403+
h := newUnitHarness(ctx, t, &Config{}, nil, nil)
404+
reqCtx, reqCancel := context.WithCancel(context.Background())
405+
item := internal.NewItem(newTestRequest(defaultFlowKey), 0, time.Now())
406+
407+
reqCancel()
408+
cancel()
409+
410+
outcome, err := h.fc.awaitFinalization(reqCtx, item)
411+
require.Error(t, err, "awaitFinalization must fail when controller shuts down")
412+
assert.ErrorIs(t, err, types.ErrFlowControllerNotRunning,
413+
"controller shutdown should take precedence over request cancellation")
414+
assert.Equal(t, types.QueueOutcomeRejectedOther, outcome,
415+
"shutdown should return the rejected outcome")
416+
})
417+
t.Run("OnControllerShutdownPreservesQueuedOutcome", func(t *testing.T) {
418+
t.Parallel()
419+
ctx, cancel := context.WithCancel(t.Context())
420+
h := newUnitHarness(ctx, t, &Config{}, nil, nil)
421+
item := internal.NewItem(newTestRequest(defaultFlowKey), 0, time.Now())
422+
item.SetHandle(&fwkfcmocks.MockQueueItemHandle{})
423+
424+
cancel()
425+
426+
outcome, err := h.fc.awaitFinalization(context.Background(), item)
427+
require.Error(t, err, "awaitFinalization must fail when controller shuts down")
428+
assert.ErrorIs(t, err, types.ErrEvicted,
429+
"a queued item should be evicted, not rejected, during shutdown")
430+
assert.ErrorIs(t, err, types.ErrFlowControllerNotRunning,
431+
"queued shutdown should preserve the shutdown cause")
432+
assert.Equal(t, types.QueueOutcomeEvictedOther, outcome,
433+
"a queued item should return the evicted outcome")
434+
})
370435

371436
t.Run("OnRegistryConnectionError", func(t *testing.T) {
372437
t.Parallel()

pkg/epp/flowcontrol/integration_test.go

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -782,6 +782,11 @@ func TestConcurrentEnqueueDuringShutdown(t *testing.T) {
782782

783783
h := newHarness(t, harnessOpts{
784784
detector: detector,
785+
controllerCfg: &controller.Config{
786+
DefaultRequestTTL: 0,
787+
ExpiryCleanupInterval: 10 * time.Millisecond,
788+
EnqueueChannelBufferSize: 100,
789+
},
785790
})
786791

787792
key := flowcontrol.FlowKey{ID: "flow-a", Priority: 0}
@@ -791,8 +796,8 @@ func TestConcurrentEnqueueDuringShutdown(t *testing.T) {
791796
for i := 0; i < numRequests; i++ {
792797
id := fmt.Sprintf("req-%d", i)
793798
go func() {
794-
req := &testRequest{id: id, key: key, byteSize: 100, ttl: 5 * time.Minute}
795-
outcome, err := h.fc.EnqueueAndWait(h.ctx, req)
799+
req := &testRequest{id: id, key: key, byteSize: 100}
800+
outcome, err := h.fc.EnqueueAndWait(context.Background(), req)
796801
results <- dispatchResult{id: id, outcome: outcome, err: err}
797802
}()
798803
}
@@ -809,6 +814,8 @@ func TestConcurrentEnqueueDuringShutdown(t *testing.T) {
809814
"no request should dispatch (detector is blocked and controller is shutting down)")
810815
require.Error(t, r.err,
811816
"every request should receive an error during shutdown")
817+
require.ErrorIs(t, r.err, fcTypes.ErrFlowControllerNotRunning,
818+
"every request should report the shutdown cause")
812819
case <-time.After(5 * time.Second):
813820
t.Fatalf("request %d hung during concurrent shutdown", i)
814821
}

pkg/epp/handlers/server.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,14 @@ type RequestContext struct {
136136
RequestDroppedReason errcommon.RequestDroppedReason
137137
modelServerStreaming bool
138138

139+
// responseProcessingDuration is the EPP cost of handling the response. For a
140+
// streamed response it is the sum of the per-chunk handler slices, since the
141+
// gaps between chunks are model server generation time. For a non-streaming
142+
// response it is the single interval from responseHeadersReceivedAt onward,
143+
// during which the response is entirely in EPP's hands.
144+
responseProcessingDuration time.Duration
145+
responseHeadersReceivedAt time.Time
146+
139147
Response *Response
140148

141149
reqHeaderResp *extProcPb.ProcessingResponse
@@ -265,6 +273,25 @@ func (s *StreamingServer) Process(srv extProcPb.ExternalProcessor_ProcessServer)
265273
},
266274
}
267275

276+
// Request-phase failures (parser resolution, body parsing, admission
277+
// rejection) leave the switch before the success path, so both call this.
278+
// Flow-control rejections carry the queue wait and are the slowest samples;
279+
// dropping them would bias the histogram low.
280+
recordRequestProcessing := sync.OnceFunc(func() {
281+
metrics.RecordRequestProcessingLatency(time.Since(reqCtx.RequestReceivedTimestamp))
282+
})
283+
284+
// Record EPP response processing latency once when the stream ends. Using a
285+
// defer (rather than emitting on end-of-stream) ensures aborted streams
286+
// (client cancel or upstream error before EOS) are also recorded, so the
287+
// metric is not biased toward fully streamed responses. The guard skips
288+
// requests that never reached the response phase.
289+
defer func() {
290+
if reqCtx.responseProcessingDuration > 0 {
291+
metrics.RecordResponseProcessingLatency(reqCtx.responseProcessingDuration)
292+
}
293+
}()
294+
268295
buf := s.bufferPool.Get().(*bytes.Buffer)
269296
buf.Reset()
270297
defer func() {
@@ -461,10 +488,13 @@ func (s *StreamingServer) Process(srv extProcPb.ExternalProcessor_ProcessServer)
461488
if parseResult.SkipResponseProcessing {
462489
reqCtx.RequestState = RequestResponseProcessingSkipped
463490
}
491+
492+
recordRequestProcessing()
464493
}
465494
case *extProcPb.ProcessingRequest_RequestTrailers:
466495
// This is currently unused.
467496
case *extProcPb.ProcessingRequest_ResponseHeaders:
497+
respHeadersReceivedAt := time.Now()
468498
for _, header := range v.ResponseHeaders.Headers.GetHeaders() {
469499
value := string(header.RawValue)
470500
loggerTrace.Info("header", "key", header.Key, "value", value)
@@ -478,12 +508,15 @@ func (s *StreamingServer) Process(srv extProcPb.ExternalProcessor_ProcessServer)
478508
reqCtx.RequestState = ResponseReceived
479509
reqCtx = s.HandleResponseHeaders(ctx, reqCtx, v)
480510
reqCtx.respHeaderResp = s.generateResponseHeaderResponse(reqCtx)
511+
reqCtx.responseHeadersReceivedAt = respHeadersReceivedAt
512+
reqCtx.responseProcessingDuration += time.Since(respHeadersReceivedAt)
481513

482514
case *extProcPb.ProcessingRequest_ResponseBody:
483515
endOfStream := v.ResponseBody.EndOfStream
484516
chunk := v.ResponseBody.Body
485517

486518
if reqCtx.modelServerStreaming {
519+
respBodyStart := time.Now()
487520
if endOfStream {
488521
reqCtx.ResponseComplete = true
489522
reqCtx.ResponseCompleteTimestamp = time.Now()
@@ -493,6 +526,7 @@ func (s *StreamingServer) Process(srv extProcPb.ExternalProcessor_ProcessServer)
493526
chunk = rewriteModelName(chunk, reqCtx.TargetModelName, reqCtx.IncomingModelName)
494527
// For streaming response, we send response chunk back to envoy every time we received it.
495528
reqCtx.respBodyResp = generateResponseBodyResponses(chunk, endOfStream, reqCtx.Response.DynamicMetadata)
529+
reqCtx.responseProcessingDuration += time.Since(respBodyStart)
496530
} else {
497531
respBody = append(respBody, chunk...)
498532
if endOfStream {
@@ -513,6 +547,7 @@ func (s *StreamingServer) Process(srv extProcPb.ExternalProcessor_ProcessServer)
513547

514548
// Handle the err and fire an immediate response.
515549
if err != nil {
550+
recordRequestProcessing()
516551
if logger.V(logutil.DEBUG).Enabled() {
517552
logger.V(logutil.DEBUG).Error(err, "Failed to process request", "request", req)
518553
} else {
@@ -553,6 +588,7 @@ func (s *StreamingServer) finishResponse(ctx context.Context, reqCtx *RequestCon
553588
return
554589
}
555590

591+
start := time.Now()
556592
reqCtx.ResponseComplete = true
557593
reqCtx.ResponseCompleteTimestamp = time.Now()
558594
reqCtx = s.HandleResponseBody(ctx, reqCtx, body, true)
@@ -562,6 +598,13 @@ func (s *StreamingServer) finishResponse(ctx context.Context, reqCtx *RequestCon
562598
// For non-streaming response, we send response back to envoy after receiving all the response body.
563599
reqCtx.respBodyResp = generateResponseBodyResponses(body, setEos, reqCtx.Response.DynamicMetadata)
564600
}
601+
if modelStreaming || reqCtx.responseHeadersReceivedAt.IsZero() {
602+
reqCtx.responseProcessingDuration += time.Since(start)
603+
} else {
604+
// Supersedes the header slice already accumulated: the interval since the
605+
// response headers arrived covers it and the body wait in between.
606+
reqCtx.responseProcessingDuration = time.Since(reqCtx.responseHeadersReceivedAt)
607+
}
565608
}
566609

567610
// rewriteModelName replaces occurrences of the target (internal) model name with the

pkg/epp/metrics/llm_d_router_metrics.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,30 @@ var (
279279
},
280280
[]string{"extension_point", "plugin_type", "plugin_name"},
281281
)
282+
283+
llmdRequestProcessingLatency = prometheus.NewHistogramVec(
284+
prometheus.HistogramOpts{
285+
Subsystem: LLMDRouterEndpointPickerSubsystem,
286+
Name: "request_processing_duration_seconds",
287+
Help: metricsutil.HelpMsgWithStability("EPP request processing latency distribution in seconds, from request receipt until the request body has been handled, including admission control.", compbasemetrics.ALPHA),
288+
Buckets: []float64{
289+
0.0005, 0.001, 0.002, 0.005, 0.01, 0.015, 0.025, 0.04, 0.06, 0.1, 0.25, 0.5, 1, 2.5, 5, 10,
290+
},
291+
},
292+
[]string{},
293+
)
294+
295+
llmdResponseProcessingLatency = prometheus.NewHistogramVec(
296+
prometheus.HistogramOpts{
297+
Subsystem: LLMDRouterEndpointPickerSubsystem,
298+
Name: "response_processing_duration_seconds",
299+
Help: metricsutil.HelpMsgWithStability("EPP response processing latency distribution in seconds: the sum of per-chunk handler time for a streamed response, or the interval from response headers to completion for a non-streaming response.", compbasemetrics.ALPHA),
300+
Buckets: []float64{
301+
0.0001, 0.00025, 0.0005, 0.001, 0.0025, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5,
302+
},
303+
},
304+
[]string{},
305+
)
282306
)
283307

284308
// --- llm-d Info Metrics ---

pkg/epp/metrics/metrics.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -464,6 +464,8 @@ func Register(customCollectors ...prometheus.Collector) {
464464
metrics.Registry.MustRegister(llmdSchedulerAttemptsTotal)
465465
metrics.Registry.MustRegister(pluginProcessingLatencies)
466466
metrics.Registry.MustRegister(llmdPluginProcessingLatencies)
467+
metrics.Registry.MustRegister(llmdRequestProcessingLatency)
468+
metrics.Registry.MustRegister(llmdResponseProcessingLatency)
467469
metrics.Registry.MustRegister(inferenceExtensionInfo)
468470
metrics.Registry.MustRegister(llmdInferenceExtensionInfo)
469471
metrics.Registry.MustRegister(flowControlRequestQueueDuration)
@@ -537,6 +539,8 @@ func Reset() {
537539
llmdSchedulerAttemptsTotal.Reset()
538540
pluginProcessingLatencies.Reset()
539541
llmdPluginProcessingLatencies.Reset()
542+
llmdRequestProcessingLatency.Reset()
543+
llmdResponseProcessingLatency.Reset()
540544
inferenceExtensionInfo.Reset()
541545
llmdInferenceExtensionInfo.Reset()
542546
flowControlRequestQueueDuration.Reset()
@@ -775,6 +779,18 @@ func RecordSchedulerE2ELatency(duration time.Duration) {
775779
llmdSchedulerE2ELatency.WithLabelValues().Observe(duration.Seconds())
776780
}
777781

782+
// RecordRequestProcessingLatency records the EPP request processing latency,
783+
// measured from request receipt until the request body has been handled.
784+
func RecordRequestProcessingLatency(duration time.Duration) {
785+
llmdRequestProcessingLatency.WithLabelValues().Observe(duration.Seconds())
786+
}
787+
788+
// RecordResponseProcessingLatency records the EPP response processing latency
789+
// for a single request.
790+
func RecordResponseProcessingLatency(duration time.Duration) {
791+
llmdResponseProcessingLatency.WithLabelValues().Observe(duration.Seconds())
792+
}
793+
778794
// RecordSchedulerAttempt records a scheduling attempt with status and endpoint information.
779795
func RecordSchedulerAttempt(err error, targetModelName string, result *fwksched.SchedulingResult) {
780796
if err != nil {

pkg/epp/metrics/metrics_test.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -881,6 +881,60 @@ func TestSchedulerE2ELatency(t *testing.T) {
881881
}
882882
}
883883

884+
func TestRequestProcessingLatency(t *testing.T) {
885+
Reset()
886+
durations := []time.Duration{
887+
200 * time.Microsecond,
888+
800 * time.Microsecond,
889+
1500 * time.Microsecond,
890+
3 * time.Millisecond,
891+
8 * time.Millisecond,
892+
15 * time.Millisecond,
893+
30 * time.Millisecond,
894+
75 * time.Millisecond,
895+
150 * time.Millisecond,
896+
}
897+
for _, duration := range durations {
898+
RecordRequestProcessingLatency(duration)
899+
}
900+
901+
want, err := os.Open("testdata/llm_d_request_processing_duration_seconds_metric")
902+
if err != nil {
903+
t.Fatal(err)
904+
}
905+
defer want.Close()
906+
if err := promtestutil.GatherAndCompare(metrics.Registry, want, "llm_d_epp_request_processing_duration_seconds"); err != nil {
907+
t.Error(err)
908+
}
909+
}
910+
911+
func TestResponseProcessingLatency(t *testing.T) {
912+
Reset()
913+
durations := []time.Duration{
914+
200 * time.Microsecond,
915+
800 * time.Microsecond,
916+
1500 * time.Microsecond,
917+
3 * time.Millisecond,
918+
8 * time.Millisecond,
919+
15 * time.Millisecond,
920+
30 * time.Millisecond,
921+
75 * time.Millisecond,
922+
150 * time.Millisecond,
923+
}
924+
for _, duration := range durations {
925+
RecordResponseProcessingLatency(duration)
926+
}
927+
928+
want, err := os.Open("testdata/llm_d_response_processing_duration_seconds_metric")
929+
if err != nil {
930+
t.Fatal(err)
931+
}
932+
defer want.Close()
933+
if err := promtestutil.GatherAndCompare(metrics.Registry, want, "llm_d_epp_response_processing_duration_seconds"); err != nil {
934+
t.Error(err)
935+
}
936+
}
937+
884938
func TestFlowControlDispatchCycleLengthMetric(t *testing.T) {
885939
Reset()
886940
scenarios := []struct {

0 commit comments

Comments
 (0)