Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .github/workflows/ci-integration-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ permissions:

jobs:
integration-tests:
name: Integration Tests (${{ matrix.file_client_type }}, ${{ matrix.db_client_type }}, ${{ matrix.exchange_client_type }}${{ matrix.enable_gie == 'true' && ', gie' || '' }})
name: Integration Tests (${{ matrix.file_client_type }}, ${{ matrix.db_client_type }}, ${{ matrix.exchange_client_type }}${{ matrix.enable_gie == 'true' && ', gie' || '' }}${{ matrix.enable_dispatcher == 'true' && ', dispatcher' || '' }})
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
Expand All @@ -45,6 +45,10 @@ jobs:
db_client_type: postgresql
exchange_client_type: redis
enable_gie: "true"
- file_client_type: s3
db_client_type: postgresql
exchange_client_type: redis
enable_dispatcher: "true"
steps:
- uses: actions/checkout@v7

Expand All @@ -65,6 +69,7 @@ jobs:
DB_CLIENT_TYPE: ${{ matrix.db_client_type }}
EXCHANGE_CLIENT_TYPE: ${{ matrix.exchange_client_type }}
ENABLE_GIE: ${{ matrix.enable_gie || 'false' }}
ENABLE_DISPATCHER: ${{ matrix.enable_dispatcher || 'false' }}

- name: Run integration tests
run: make test-e2e
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
bin/
.build/
.vscode/
.claude/
.cursor/
Expand All @@ -8,3 +9,5 @@ __pycache__/
*.pyc
benchmarks/results/*
!benchmarks/results/.gitkeep
.dispatcher-port-forward.pid
.dispatcher-sim-port-forward.pid
22 changes: 19 additions & 3 deletions charts/batch-gateway/templates/processor-configmap.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,23 @@ data:
conn_max_idle_time: {{ .Values.global.dbClient.redis.connMaxIdleTime | quote }}
conn_max_lifetime: {{ .Values.global.dbClient.redis.connMaxLifetime | quote }}

{{- if .Values.processor.config.dispatchMode }}
dispatch_mode: {{ .Values.processor.config.dispatchMode | quote }}
{{- end }}
{{- if .Values.processor.config.asyncDispatch }}
async_dispatch:
result_poll_timeout: {{ .Values.processor.config.asyncDispatch.resultPollTimeout | quote }}
{{- end }}

{{- with .Values.processor.config.globalInferenceGateway }}
global_inference_gateway:
url: {{ .url | quote }}
{{- if .inferenceObjective }}
inference_objective: {{ .inferenceObjective | quote }}
{{- end }}
{{- if .inferencePoolName }}
inference_pool_name: {{ .inferencePoolName | quote }}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do you need inference_pool_name for global_inference_gateway?

because i saw this in below

return fmt.Errorf("global_inference_gateway is not supported with dispatch_mode %q; use model_gateways with inference_pool_name", DispatchModeAsync)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no, global_inference_gateway doesn't need inference_pool_name. Async mode explicitly rejects global_inference_gateway, validation fails with that error message.

inference_pool_name is only needed on model_gateways entries, which should be where the template renders it. The error message is telling users to switch from global_inference_gateway to model_gateways when using async mode

we can do amendments to the config format in another PR anyway

{{- end }}
request_timeout: {{ .requestTimeout | quote }}
max_retries: {{ .maxRetries }}
initial_backoff: {{ .initialBackoff | quote }}
Expand All @@ -74,15 +85,20 @@ data:
model_gateways:
{{- range $model, $cfg := .Values.processor.config.modelGateways }}
{{ $model | quote }}:
url: {{ $cfg.url | quote }}
{{- if $cfg.inferenceObjective }}
inference_objective: {{ $cfg.inferenceObjective | quote }}
{{- if $cfg.inferencePoolName }}
inference_pool_name: {{ $cfg.inferencePoolName | quote }}
{{- end }}
{{- if $cfg.url }}
url: {{ $cfg.url | quote }}
request_timeout: {{ $cfg.requestTimeout | quote }}
max_retries: {{ $cfg.maxRetries }}
initial_backoff: {{ $cfg.initialBackoff | quote }}
max_backoff: {{ $cfg.maxBackoff | quote }}
tls_insecure_skip_verify: {{ $cfg.tlsInsecureSkipVerify | default false }}
{{- end }}
{{- if $cfg.inferenceObjective }}
inference_objective: {{ $cfg.inferenceObjective | quote }}
{{- end }}
{{- if $cfg.apiKeyName }}
api_key_name: {{ $cfg.apiKeyName | quote }}
{{- end }}
Expand Down
16 changes: 13 additions & 3 deletions cmd/batch-processor/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -296,19 +296,29 @@ func buildProcessorClients(ctx context.Context, cfg *config.ProcessorConfig) (*c
if len(resolved.PerModel) > 0 {
opts = append(opts, clientset.WithPerModelInference(resolved.PerModel))
}
if resolved.Async != nil {
opts = append(opts, clientset.WithAsyncInference(*resolved.Async))
}
clients, err := clientset.NewClientset(ctx, ucom.ComponentProcessor, opts...)
if err != nil {
logger.Error(err, "Failed to create clients")
return nil, err
}

// Validate() guarantees exactly one of resolved.Global or resolved.PerModel is set.
if resolved.Global != nil {
// ResolveModelGateways populates exactly one of Async, Global, or PerModel
// based on the dispatch mode validated by Validate().
switch {
case resolved.Async != nil:
logger.V(logging.INFO).Info("Processor clients initialized",
"mode", "async",
"numModels", len(resolved.Async.Models),
"fileClientType", cfg.FileClientCfg.Type)
case resolved.Global != nil:
logger.V(logging.INFO).Info("Processor clients initialized",
"mode", "global",
"gatewayURL", resolved.Global.URL,
"fileClientType", cfg.FileClientCfg.Type)
} else {
default:
logger.V(logging.INFO).Info("Processor clients initialized",
"mode", "per-model",
"numModelGateways", len(resolved.PerModel),
Expand Down
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ require (
github.com/go-resty/resty/v2 v2.17.2
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.10.0
github.com/llm-d-incubation/llm-d-async/api v0.7.2
github.com/llm-d-incubation/llm-d-async/producer v0.7.2
github.com/pashagolub/pgxmock/v4 v4.9.0
github.com/prometheus/client_golang v1.23.2
github.com/quasilyte/go-ruleguard/dsl v0.3.23
Expand Down
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,10 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/llm-d-incubation/llm-d-async/api v0.7.2 h1:sf6iFDa5LpVoKDYiOOlsbnv+6Ykj5TA22yRqMdIbOfY=
github.com/llm-d-incubation/llm-d-async/api v0.7.2/go.mod h1:m2zJUwD/AZypJv8RPws3uvCnfQoNW7iv+hH9wPk/Xw4=
github.com/llm-d-incubation/llm-d-async/producer v0.7.2 h1:hhnLqi+MXq8kBjsQhnUCWYtTCG6uFTZLCH296CZMlpY=
github.com/llm-d-incubation/llm-d-async/producer v0.7.2/go.mod h1:IrClO4XwMtgLRnlkAqO1LDsuqmwtQjELDabT2f+5d40=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/pashagolub/pgxmock/v4 v4.9.0 h1:itlO8nrVRnzkdMBXLs8pWUyyB2PC3Gku0WGIj/gGl7I=
Expand Down
61 changes: 26 additions & 35 deletions internal/processor/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -230,18 +230,6 @@ type BucketConfig struct {
BucketCount int `yaml:"count"`
}

const asyncTenantID = "$batch"

// RequestQueueName returns the Redis sorted-set name for submitting async requests to the given pool.
func RequestQueueName(poolName string) string {
return "llm-d-async:requests:" + poolName
}

// ResultQueueName returns the Redis list name for collecting async results from the given pool.
func ResultQueueName(poolName string) string {
return "llm-d-async:results:" + poolName + ":" + asyncTenantID
}

Comment thread
evacchi marked this conversation as resolved.
// IsAsync returns true when the processor is configured for async dispatch.
func (c *ProcessorConfig) IsAsync() bool {
return c.DispatchMode == DispatchModeAsync
Expand Down Expand Up @@ -386,14 +374,21 @@ func (c *ProcessorConfig) Validate() error {
return fmt.Errorf("progress_ttl_seconds must be > 0")
}

if err := c.validateDispatchMode(); err != nil {
if err := c.validateGateways(); err != nil {
return err
}

return nil
}

func (c *ProcessorConfig) validateDispatchMode() error {
func (c *ProcessorConfig) validateGateways() error {
if c.GlobalInferenceGateway == nil && len(c.ModelGateways) == 0 {
return fmt.Errorf("either global_inference_gateway or model_gateways must be configured")
}
if c.GlobalInferenceGateway != nil && len(c.ModelGateways) > 0 {
return fmt.Errorf("global_inference_gateway and model_gateways are mutually exclusive")
}

switch c.DispatchMode {
case DispatchModeSync, DispatchMode(""):
c.DispatchMode = DispatchModeSync
Expand All @@ -405,21 +400,7 @@ func (c *ProcessorConfig) validateDispatchMode() error {
}
}

func (c *ProcessorConfig) validateGateways() error {
if c.GlobalInferenceGateway == nil && len(c.ModelGateways) == 0 {
return fmt.Errorf("either global_inference_gateway or model_gateways must be configured")
}
if c.GlobalInferenceGateway != nil && len(c.ModelGateways) > 0 {
return fmt.Errorf("global_inference_gateway and model_gateways are mutually exclusive")
}
return nil
}

func (c *ProcessorConfig) validateSyncDispatchConfig() error {
if err := c.validateGateways(); err != nil {
return err
}

if c.GlobalInferenceGateway != nil {
if err := validateGatewayConfig("global_inference_gateway", *c.GlobalInferenceGateway); err != nil {
return err
Expand All @@ -435,15 +416,13 @@ func (c *ProcessorConfig) validateSyncDispatchConfig() error {

func (c *ProcessorConfig) validateAsyncDispatchConfig() error {
if c.AsyncDispatchConfig.ResultPollTimeout <= 0 {
return fmt.Errorf("async.result_poll_timeout must be > 0")
}
if err := c.validateGateways(); err != nil {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: don't you think all of this is still gateways validation? Maybe all the logic should be in that single function validateGateways and maybe we could even drop the switch case.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I folded the logic for sync/async+validateDispatchMode into validateGateways, I hope that's what you meant.

return err
return fmt.Errorf("async_dispatch.result_poll_timeout must be > 0")
Comment thread
evacchi marked this conversation as resolved.
}
if c.GlobalInferenceGateway != nil {
if c.GlobalInferenceGateway.InferencePoolName == "" {
return fmt.Errorf("global_inference_gateway.inference_pool_name must be set when dispatch_mode is %q", DispatchModeAsync)
}
return fmt.Errorf("global_inference_gateway is not supported with dispatch_mode %q; use model_gateways with inference_pool_name", DispatchModeAsync)
}
if len(c.ModelGateways) == 0 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wasn't introduced by this PR but I think having InferencePoolName as the only useful field for async buried in ModelGateways where all the other fields are effectively ignored is not the best API design, maybe we should have a separate config for Async?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

agreed, but this should be addressed separately, let's create an issue

@evacchi evacchi Jun 30, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

created #526

return fmt.Errorf("model_gateways must be configured when dispatch_mode is %q", DispatchModeAsync)
}
for model, gw := range c.ModelGateways {
if gw.InferencePoolName == "" {
Expand Down Expand Up @@ -580,6 +559,7 @@ func toGatewayClientConfig(gw ModelGatewayConfig, apiKey string) inference.Gatew
type ResolvedGateways struct {
Global *inference.GatewayClientConfig
PerModel map[string]inference.GatewayClientConfig
Async *inference.AsyncClientConfig
}

// ResolveModelGateways resolves API keys for all configured gateways and returns
Expand All @@ -588,6 +568,17 @@ type ResolvedGateways struct {
func ResolveModelGateways(cfg *ProcessorConfig) (*ResolvedGateways, error) {
result := &ResolvedGateways{}

if cfg.IsAsync() {
models := make(map[string]string, len(cfg.ModelGateways))
for model, gw := range cfg.ModelGateways {
models[model] = gw.InferencePoolName
}
result.Async = &inference.AsyncClientConfig{
Models: models,
}
return result, nil
}

if cfg.GlobalInferenceGateway != nil {
apiKey, err := resolveGatewayAPIKey("global_inference_gateway", *cfg.GlobalInferenceGateway)
if err != nil {
Expand Down
68 changes: 57 additions & 11 deletions internal/processor/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -715,12 +715,12 @@ func TestProcessorConfig_Validate_AsyncDispatch(t *testing.T) {
wantErr: true,
},
{
name: "async valid global gateway with inference_pool_name",
name: "async global gateway rejected",
mutate: func(c *ProcessorConfig) {
c.ModelGateways = nil
c.GlobalInferenceGateway = &ModelGatewayConfig{URL: "http://gw:8000", InferencePoolName: "default-pool"}
},
wantErr: false,
wantErr: true,
},
{
name: "async no gateways configured",
Expand Down Expand Up @@ -784,15 +784,6 @@ func TestProcessorConfig_Validate_AsyncDispatch(t *testing.T) {
}
}

func TestQueueNameHelpers(t *testing.T) {
if got := RequestQueueName("pool-a"); got != "llm-d-async:requests:pool-a" {
t.Fatalf("RequestQueueName(\"pool-a\") = %q, want %q", got, "llm-d-async:requests:pool-a")
}
if got := ResultQueueName("pool-a"); got != "llm-d-async:results:pool-a:$batch" {
t.Fatalf("ResultQueueName(\"pool-a\") = %q, want %q", got, "llm-d-async:results:pool-a:$batch")
}
}

func TestValidate_NormalizesEmptyDispatchMode(t *testing.T) {
c := NewConfig()
c.ModelGateways = validPerModelConfig()
Expand Down Expand Up @@ -919,3 +910,58 @@ func TestProcessorConfig_InferenceObjectiveFor(t *testing.T) {
})
}
}

func TestResolveModelGateways_Async(t *testing.T) {
t.Run("populates Async field", func(t *testing.T) {
cfg := NewConfig()
cfg.DispatchMode = DispatchModeAsync
cfg.AsyncDispatchConfig = AsyncDispatchConfig{
ResultPollTimeout: 10 * time.Second,
}
cfg.ModelGateways = map[string]ModelGatewayConfig{
"model-a": {InferencePoolName: "pool-a"},
"model-b": {InferencePoolName: "pool-b"},
}

resolved, err := ResolveModelGateways(cfg)
if err != nil {
t.Fatalf("ResolveModelGateways() error: %v", err)
}

if resolved.Async == nil {
t.Fatal("expected Async to be set")
}
if resolved.Global != nil {
t.Error("expected Global to be nil in async mode")
}
if resolved.PerModel != nil {
t.Error("expected PerModel to be nil in async mode")
}
if len(resolved.Async.Models) != 2 {
t.Fatalf("Models count = %d, want 2", len(resolved.Async.Models))
}
if resolved.Async.Models["model-a"] != "pool-a" {
t.Errorf("Models[model-a] = %q, want %q", resolved.Async.Models["model-a"], "pool-a")
}
if resolved.Async.Models["model-b"] != "pool-b" {
t.Errorf("Models[model-b] = %q, want %q", resolved.Async.Models["model-b"], "pool-b")
}
})

t.Run("sync mode does not populate Async", func(t *testing.T) {
cfg := NewConfig()
cfg.ModelGateways = validPerModelConfig()

resolved, err := ResolveModelGateways(cfg)
if err != nil {
t.Fatalf("ResolveModelGateways() error: %v", err)
}

if resolved.Async != nil {
t.Error("expected Async to be nil in sync mode")
}
if len(resolved.PerModel) == 0 {
t.Error("expected PerModel to be populated in sync mode")
}
})
}
Loading
Loading