Skip to content

Commit 4753e49

Browse files
authored
feat: query dispatch gate (llm-d#194)
* feat: query dispatch gate Signed-off-by: Edoardo Vacchi <evacchi@users.noreply.github.com> * address comments Signed-off-by: Edoardo Vacchi <evacchi@users.noreply.github.com> --------- Signed-off-by: Edoardo Vacchi <evacchi@users.noreply.github.com>
1 parent ee14955 commit 4753e49

7 files changed

Lines changed: 269 additions & 0 deletions

File tree

README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ For more fine-grained control, configure gates per queue in your configuration f
105105
- `composite`: Combines multiple gates. Returns the minimum budget across all inner dispatch gates and acquires quota across all inner attribute gates (all or nothing).
106106
- `prometheus-saturation`: Queries Prometheus for pool saturation metric. The gate closes (returns `0.0`) when saturation ≥ threshold; when open it returns `(1 - saturation) - (1 - threshold)`, i.e. the margin below the threshold.
107107
- `prometheus-budget`: Computes a dispatch budget D using a cascade of two Prometheus metric sources. Both sources compute `max_SYS = ready_pods × max_concurrency` dynamically. The primary source uses the EPP flow control queue size: `D = 1 − (queue_size / max_SYS)`. If the primary is unavailable, it falls back to a secondary source using vLLM and pool metrics: `D = 1 − (running_requests / max_SYS)`. The gate closes when D ≤ B (baseline); callers compute `N = max_SYS × (D − B)`. See [docs/dispatch-budget.md](docs/dispatch-budget.md) for details.
108+
- `prometheus-query`: Evaluates an arbitrary user-supplied PromQL expression as the dispatch budget. The expression must resolve to an instant vector with a single sample whose value is in [0, 1]. Unlike `prometheus-saturation` and `prometheus-budget`, this gate does not construct queries internally — the user provides the complete PromQL expression. Values outside [0, 1] are clamped.
108109

109110
**Example Configuration with Per-Queue Gates:**
110111

@@ -147,6 +148,16 @@ For more fine-grained control, configure gates per queue in your configuration f
147148
"budget_key": "my-budget-key"
148149
}
149150
},
151+
{
152+
"queue_name": "custom_metric_queue",
153+
"inference_objective": "custom-task",
154+
"request_path_url": "/v1/inference",
155+
"gate_type": "prometheus-query",
156+
"gate_params": {
157+
"query": "1 - (sum(rate(http_requests_total{job=\"inference\"}[5m])) / 100)",
158+
"fallback": "0.0"
159+
}
160+
},
150161
{
151162
"queue_name": "composite_gated_queue",
152163
"inference_objective": "composite-task",
@@ -211,6 +222,15 @@ For more fine-grained control, configure gates per queue in your configuration f
211222
targetLabel: inference_pool
212223
```
213224

225+
- `prometheus-query`: Evaluates a user-supplied PromQL expression directly as the dispatch budget.
226+
The expression must resolve to a Prometheus instant vector with a single sample whose value is in [0, 1].
227+
Values outside this range are clamped.
228+
229+
- `query` (**required**): The PromQL expression to evaluate. This is sent to Prometheus exactly as provided.
230+
The result is used directly as the dispatch budget (no transformation is applied).
231+
- `fallback` (optional): Fallback budget value (0.0-1.0) returned when the query fails or returns no data.
232+
Default is `0.0` (fail closed).
233+
214234
## Request Messages and Consumption
215235

216236
The async processor expects request messages to have the following format:

pkg/async/inference/flowcontrol/gate_factory.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,11 @@ func (f *GateFactory) Close() error {
8787
// Gate closes when D ≤ B (baseline); returns D − B when open, so callers compute
8888
// N = max_SYS × (D − B). Params: pool (required),
8989
// max_concurrency (default 100), baseline (default 0.05), fallback (default 0.0)
90+
// - "prometheus-query": Evaluates an arbitrary user-supplied PromQL expression as the dispatch
91+
// budget. The expression must resolve to an instant vector with a single sample whose value
92+
// is in [0, 1]. Unlike prometheus-saturation and prometheus-budget, this gate does not
93+
// construct queries internally — the user provides the complete PromQL expression.
94+
// Params: query (required), fallback (default 0.0)
9095
//
9196
// For unsupported or unknown gate types, returns ConstOpenGate as a safe default.
9297
func (f *GateFactory) CreateGate(gateType string, params map[string]string) (pipeline.DispatchGate, error) {
@@ -255,6 +260,28 @@ func (f *GateFactory) CreateGate(gateType string, params map[string]string) (pip
255260
)
256261
return NewBudgetDispatchGate(ms, baseline, fallback), nil
257262

263+
case "prometheus-query":
264+
if f.prometheusURL == "" {
265+
return nil, fmt.Errorf("prometheus-query gate type requires --prometheus-url flag to be set")
266+
}
267+
268+
query := params["query"]
269+
if query == "" {
270+
return nil, fmt.Errorf("prometheus-query gate requires a 'query' parameter with a PromQL expression")
271+
}
272+
273+
fallback, err := parseFloat("fallback", params["fallback"], 0.0)
274+
if err != nil {
275+
return nil, err
276+
}
277+
278+
promConfig := promapi.Config{Address: f.prometheusURL}
279+
source, err := NewPromQLMetricSource(promConfig, query)
280+
if err != nil {
281+
return nil, err
282+
}
283+
return NewMetricDispatchGate(cachedSource(source, f.cacheTTL), 0.0, fallback), nil
284+
258285
default:
259286
// Unknown gate types default to open gate
260287
return ConstOpenGate(), nil

pkg/async/inference/flowcontrol/gate_factory_test.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,3 +249,51 @@ func TestGateFactory_BudgetGateWithAllParams(t *testing.T) {
249249
assert.NoError(t, err)
250250
assert.NotNil(t, gate)
251251
}
252+
253+
func TestGateFactory_PrometheusQueryGateWithoutURL(t *testing.T) {
254+
factory := NewGateFactory("")
255+
gate, err := factory.CreateGate("prometheus-query", map[string]string{
256+
"query": "up",
257+
})
258+
assert.Error(t, err, "should return error when Prometheus URL is not set")
259+
assert.Nil(t, gate)
260+
assert.Contains(t, err.Error(), "prometheus-query gate type requires --prometheus-url flag to be set")
261+
}
262+
263+
func TestGateFactory_PrometheusQueryGateMissingQuery(t *testing.T) {
264+
factory := NewGateFactory("http://localhost:9090")
265+
gate, err := factory.CreateGate("prometheus-query", map[string]string{})
266+
assert.Error(t, err, "should return error when query parameter is missing")
267+
assert.Nil(t, gate)
268+
assert.Contains(t, err.Error(), "requires a 'query' parameter")
269+
}
270+
271+
func TestGateFactory_PrometheusQueryGateWithInvalidFallback(t *testing.T) {
272+
factory := NewGateFactory("http://localhost:9090")
273+
gate, err := factory.CreateGate("prometheus-query", map[string]string{
274+
"query": "up",
275+
"fallback": "not-a-number",
276+
})
277+
assert.Error(t, err, "should return error when fallback is not a valid float")
278+
assert.Nil(t, gate)
279+
assert.Contains(t, err.Error(), "invalid fallback value")
280+
}
281+
282+
func TestGateFactory_PrometheusQueryGateWithDefaults(t *testing.T) {
283+
factory := NewGateFactory("http://localhost:9090")
284+
gate, err := factory.CreateGate("prometheus-query", map[string]string{
285+
"query": "up",
286+
})
287+
assert.NoError(t, err, "should create gate with default fallback")
288+
assert.NotNil(t, gate)
289+
}
290+
291+
func TestGateFactory_PrometheusQueryGateWithAllParams(t *testing.T) {
292+
factory := NewGateFactory("http://localhost:9090")
293+
gate, err := factory.CreateGate("prometheus-query", map[string]string{
294+
"query": `1 - (sum(rate(http_requests_total[5m])) / 100)`,
295+
"fallback": "0.5",
296+
})
297+
assert.NoError(t, err, "should create gate with all params specified")
298+
assert.NotNil(t, gate)
299+
}

test/e2e/e2e_query_gate_test.go

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
package e2e
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"time"
7+
8+
"github.com/onsi/ginkgo/v2"
9+
"github.com/onsi/gomega"
10+
)
11+
12+
const (
13+
queryRequestQueue = "query-request-sortedset"
14+
queryResultQueue = "query-result-list"
15+
)
16+
17+
// The prometheus-query gate evaluates an arbitrary PromQL expression as the
18+
// dispatch budget. The e2e Helm values configure it with the same saturation
19+
// metric used by the saturation gate tests:
20+
//
21+
// 1 - inference_extension_flow_control_pool_saturation{inference_pool="e2e-pool"}
22+
//
23+
// Because threshold=0 the raw PromQL result is used directly as the budget.
24+
var _ = ginkgo.Describe("Prometheus Query Dispatch Gate E2E", ginkgo.Ordered, func() {
25+
var ctx context.Context
26+
27+
ginkgo.BeforeAll(func() {
28+
redeployEPPWithFlowControl()
29+
})
30+
31+
ginkgo.BeforeEach(func() {
32+
ctx = context.Background()
33+
rdb.Del(ctx, queryRequestQueue) //nolint:errcheck
34+
rdb.Del(ctx, queryResultQueue) //nolint:errcheck
35+
setSimWaitingRequests(simAdminURL, 0)
36+
})
37+
38+
ginkgo.It("processes a message when the query returns a positive budget", func() {
39+
setSimWaitingRequests(simAdminURL, 0)
40+
41+
waitForSaturation(promURL, envoyURL, func(v float64) bool { return v < 0.5 })
42+
43+
msg := makeRequestMessage("query-positive", 5*time.Minute)
44+
enqueueMessage(ctx, rdb, queryRequestQueue, msg)
45+
46+
gomega.Eventually(func() int64 {
47+
return getResultCount(ctx, rdb, queryResultQueue)
48+
}, 60*time.Second, 1*time.Second).Should(gomega.BeNumerically(">=", 1))
49+
50+
result := popResult(ctx, rdb, queryResultQueue)
51+
gomega.Expect(result).NotTo(gomega.BeNil())
52+
gomega.Expect(result.ID).To(gomega.Equal("query-positive"))
53+
})
54+
55+
ginkgo.It("pauses processing when the query returns zero budget", func() {
56+
setSimWaitingRequests(simAdminURL, 10)
57+
58+
waitForSaturation(promURL, envoyURL, func(v float64) bool { return v >= 0.7 })
59+
60+
msg := makeRequestMessage("query-blocked", 5*time.Minute)
61+
enqueueMessage(ctx, rdb, queryRequestQueue, msg)
62+
63+
gomega.Consistently(func() int64 {
64+
return getResultCount(ctx, rdb, queryResultQueue)
65+
}, 10*time.Second, 1*time.Second).Should(gomega.Equal(int64(0)))
66+
67+
setSimWaitingRequests(simAdminURL, 0)
68+
69+
gomega.Eventually(func() int64 {
70+
return getResultCount(ctx, rdb, queryResultQueue)
71+
}, 60*time.Second, 1*time.Second).Should(gomega.BeNumerically(">=", 1))
72+
73+
result := popResult(ctx, rdb, queryResultQueue)
74+
gomega.Expect(result).NotTo(gomega.BeNil())
75+
gomega.Expect(result.ID).To(gomega.Equal("query-blocked"))
76+
})
77+
78+
ginkgo.It("resumes processing when the query budget recovers", func() {
79+
setSimWaitingRequests(simAdminURL, 10)
80+
waitForSaturation(promURL, envoyURL, func(v float64) bool { return v >= 0.7 })
81+
82+
for i := 1; i <= 3; i++ {
83+
msg := makeRequestMessage(fmt.Sprintf("query-resume-%d", i), 5*time.Minute)
84+
enqueueMessage(ctx, rdb, queryRequestQueue, msg)
85+
}
86+
87+
gomega.Consistently(func() int64 {
88+
return getResultCount(ctx, rdb, queryResultQueue)
89+
}, 5*time.Second, 1*time.Second).Should(gomega.Equal(int64(0)))
90+
91+
setSimWaitingRequests(simAdminURL, 0)
92+
93+
gomega.Eventually(func() int64 {
94+
return getResultCount(ctx, rdb, queryResultQueue)
95+
}, 60*time.Second, 1*time.Second).Should(gomega.BeNumerically(">=", 3))
96+
})
97+
})

test/e2e/e2e_suite_test.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,7 @@ func applyManifests() {
319319
{"redis-gate", helmValuesDir + "/redis-gate.yaml"},
320320
{"quota", helmValuesDir + "/quota.yaml"},
321321
{"composite", helmValuesDir + "/composite.yaml"},
322+
{"prometheus-query", helmValuesDir + "/prometheus-query.yaml"},
322323
} {
323324
helmInstall(r.name, r.values, map[string]string{
324325
"ap.image.repository": imageRepo,
@@ -534,6 +535,7 @@ func doRedeployEPPWithFlowControl() {
534535
"redis-gate-async-processor",
535536
"quota-async-processor",
536537
"composite-async-processor",
538+
"prometheus-query-async-processor",
537539
} {
538540
cmd := exec.Command("kubectl", "--kubeconfig", kindKubeconfig,
539541
"-n", nsName, "rollout", "restart", "deployment/"+deploy)
@@ -548,6 +550,7 @@ func doRedeployEPPWithFlowControl() {
548550
"redis-gate-async-processor",
549551
"quota-async-processor",
550552
"composite-async-processor",
553+
"prometheus-query-async-processor",
551554
} {
552555
cmd := exec.Command("kubectl", "--kubeconfig", kindKubeconfig,
553556
"-n", nsName, "rollout", "status", "deployment/"+deploy, "--timeout=120s")
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
ap:
2+
imagePullPolicy: Never
3+
messageQueueImpl: "redis-sortedset"
4+
igwBaseURL: "http://envoy.e2e-integration.svc.cluster.local:8081"
5+
concurrency: 1
6+
prometheusURL: "http://prometheus.e2e-integration.svc.cluster.local:9090"
7+
prometheusCacheTTL: "0s"
8+
metrics:
9+
enabled: true
10+
port: 9090
11+
secure: false
12+
redis:
13+
enabled: true
14+
url: "redis://redis.e2e-integration.svc.cluster.local:6379"
15+
requestPathURL: "/v1/completions"
16+
requestQueueName: "query-request-sortedset"
17+
resultQueueName: "query-result-list"
18+
pollIntervalMs: 500
19+
batchSize: 10
20+
gateType: "prometheus-query"
21+
gateParams:
22+
query: "1 - inference_extension_flow_control_pool_saturation{inference_pool=\"e2e-pool\"}"
23+
fallback: "0.0"
24+
modelServerMonitor:
25+
enabled: false

test/integration/prometheus_gate_factory_test.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,55 @@ func TestGateFactory_PrometheusSaturation_MissingParams(t *testing.T) {
125125
assert.Error(t, err, "Should fail when pool is missing")
126126
}
127127

128+
// TestGateFactory_PrometheusQuery_EndToEnd validates the prometheus-query gate
129+
// wiring: GateFactory.CreateGate("prometheus-query", params) ->
130+
//
131+
// NewPromQLMetricSource (user-supplied PromQL) ->
132+
// CachedMetricSource -> MetricDispatchGate(threshold=0) -> Budget()
133+
func TestGateFactory_PrometheusQuery_EndToEnd(t *testing.T) {
134+
var queryCount int64
135+
body := &atomic.Value{}
136+
body.Store(promVectorResponse("0.7"))
137+
138+
const promQL = "my_custom_metric"
139+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
140+
atomic.AddInt64(&queryCount, 1)
141+
assert.Equal(t, promQL, r.FormValue("query"), "gate should forward the user-supplied PromQL expression")
142+
w.Header().Set("Content-Type", "application/json")
143+
fmt.Fprint(w, body.Load().(string))
144+
}))
145+
defer server.Close()
146+
147+
factory := flowcontrol.NewGateFactoryWithCacheTTL(server.URL, 200*time.Millisecond)
148+
149+
gate, err := factory.CreateGate("prometheus-query", map[string]string{
150+
"query": promQL,
151+
"fallback": "0.0",
152+
})
153+
require.NoError(t, err)
154+
155+
ctx := context.Background()
156+
157+
// threshold=0 so Budget = value - 0 = 0.7.
158+
budget := gate.Budget(ctx)
159+
assert.InDelta(t, 0.7, budget, 0.01)
160+
assert.Equal(t, int64(1), atomic.LoadInt64(&queryCount))
161+
162+
// Second call within cache TTL should NOT trigger another HTTP request.
163+
budget2 := gate.Budget(ctx)
164+
assert.InDelta(t, 0.7, budget2, 0.01)
165+
assert.Equal(t, int64(1), atomic.LoadInt64(&queryCount), "Should use cached result")
166+
167+
// Wait for cache TTL to expire and update response.
168+
time.Sleep(250 * time.Millisecond)
169+
body.Store(promVectorResponse("0.0"))
170+
171+
// Query returns 0.0 => Budget = 0.0.
172+
budget3 := gate.Budget(ctx)
173+
assert.Equal(t, 0.0, budget3)
174+
assert.Equal(t, int64(2), atomic.LoadInt64(&queryCount), "Should have queried Prometheus again after cache expiry")
175+
}
176+
128177
func promVectorResponse(value string) string {
129178
return fmt.Sprintf(`{"status":"success","data":{"resultType":"vector","result":[{"metric":{"__name__":"test","inference_pool":"my-pool"},"value":[1234567890,"%s"]}]}}`, value)
130179
}

0 commit comments

Comments
 (0)