Skip to content

Commit 3c36553

Browse files
authored
feat(batch): planner-batch-intergation test (vllm-project#2186)
* planner-batch-intergation * planner--job-id-and-batch-ip-mapping * planner-logging-refactor * revert-mock-app * address-review-comments Signed-off-by: nwangfw <113055713+nwangfw@users.noreply.github.com> Signed-off-by: Ning Wang <n.wang.chn@hotmail.com>
1 parent 76a5b00 commit 3c36553

11 files changed

Lines changed: 375 additions & 360 deletions

File tree

apps/console/api/handler/job.go

Lines changed: 90 additions & 165 deletions
Original file line numberDiff line numberDiff line change
@@ -16,33 +16,28 @@ limitations under the License.
1616

1717
// JobHandler implements the Console BFF JobService:
1818
//
19-
// - Calls the metadata service /v1/batches API via the official OpenAI Go
20-
// SDK (openai-go v3). Talking to the metadata service through the SDK
21-
// keeps it honest about being OpenAI-compatible — schema drift on the
22-
// upstream side surfaces immediately as a deserialization or 4xx error.
19+
// - Routes all job lifecycle calls (Enqueue / Get / List / Cancel)
20+
// through the Planner. The Planner owns JobID -> MDS batch.ID
21+
// translation; the handler never holds an MDS batch.ID.
2322
// - Persists Console-owned fields (id, display name, created_by, future:
2423
// organization, tags ...) in the local store.
25-
// - Aggregates both sources into the wire-level *pb.Job returned to the UI.
24+
// - Aggregates Planner Jobs into the wire-level *pb.Job returned to the UI.
2625
//
27-
// The AIBrix-only extension `aibrix.model_template` is passed via the SDK's
28-
// `option.WithJSONSet`, which is the OpenAI-recommended `extra_body` channel.
26+
// The AIBrix-only extension `aibrix.model_template` is forwarded to MDS by the
27+
// planner's BatchClient via the OpenAI SDK's `extra_body` channel.
2928
//
30-
// When the metadata service is unreachable the handler propagates the error
31-
// (codes.Unavailable). The frontend renders its mock fallback in that case.
29+
// When the planner / metadata service is unreachable the handler propagates
30+
// the error (codes.Unavailable). The frontend renders its mock fallback in
31+
// that case.
3232
package handler
3333

3434
import (
35-
"bytes"
3635
"context"
37-
"encoding/json"
3836
"errors"
39-
"fmt"
40-
"io"
4137
"net/http"
42-
"strings"
4338

39+
"github.com/google/uuid"
4440
"github.com/openai/openai-go/v3"
45-
"github.com/openai/openai-go/v3/option"
4641
"google.golang.org/grpc/codes"
4742
"google.golang.org/grpc/status"
4843
"k8s.io/klog/v2"
@@ -53,75 +48,6 @@ import (
5348
"github.com/vllm-project/aibrix/apps/console/api/store"
5449
)
5550

56-
// loggingTransport dumps every request and response between the BFF and MDS
57-
// at klog -v=2 (verbose). Errors and non-2xx responses always log at info.
58-
// Enable with `--v=2` (or env KLOG_V=2) when debugging the OpenAI SDK payloads.
59-
type loggingTransport struct {
60-
base http.RoundTripper
61-
}
62-
63-
func (t *loggingTransport) RoundTrip(req *http.Request) (*http.Response, error) {
64-
verbose := klog.V(2).Enabled()
65-
66-
var reqBody []byte
67-
if verbose && req.Body != nil {
68-
var err error
69-
reqBody, err = io.ReadAll(req.Body)
70-
if err != nil {
71-
return nil, fmt.Errorf("read request body for logging: %w", err)
72-
}
73-
req.Body = io.NopCloser(bytes.NewReader(reqBody))
74-
}
75-
if verbose {
76-
klog.V(2).Infof("[BFF→MDS] %s %s\n%s", req.Method, req.URL.String(), prettyBody(reqBody))
77-
}
78-
79-
resp, err := t.base.RoundTrip(req)
80-
if err != nil {
81-
klog.Warningf("[BFF→MDS] %s %s ERROR %v", req.Method, req.URL.String(), err)
82-
return resp, err
83-
}
84-
85-
if resp.StatusCode >= 400 || verbose {
86-
respBody, rerr := io.ReadAll(resp.Body)
87-
_ = resp.Body.Close()
88-
if rerr != nil {
89-
klog.Warningf("[BFF→MDS] %s %s -> %d (read body failed: %v)",
90-
req.Method, req.URL.String(), resp.StatusCode, rerr)
91-
return resp, nil
92-
}
93-
resp.Body = io.NopCloser(bytes.NewReader(respBody))
94-
if resp.StatusCode >= 400 {
95-
klog.Warningf("[BFF→MDS] %s %s -> %d\n%s", req.Method, req.URL.String(), resp.StatusCode, prettyBody(respBody))
96-
} else {
97-
klog.V(2).Infof("[BFF→MDS] %s %s -> %d\n%s", req.Method, req.URL.String(), resp.StatusCode, prettyBody(respBody))
98-
}
99-
}
100-
return resp, nil
101-
}
102-
103-
const maxLoggedBodyBytes = 8192
104-
105-
// prettyBody indents JSON for readability and truncates oversized bodies.
106-
// Non-JSON bodies are returned as-is (also truncated).
107-
func prettyBody(b []byte) string {
108-
if len(b) == 0 {
109-
return "(empty)"
110-
}
111-
var pretty bytes.Buffer
112-
if err := json.Indent(&pretty, b, "", " "); err == nil {
113-
out := pretty.Bytes()
114-
if len(out) > maxLoggedBodyBytes {
115-
return string(out[:maxLoggedBodyBytes]) + "\n...(truncated)"
116-
}
117-
return string(out)
118-
}
119-
if len(b) > maxLoggedBodyBytes {
120-
return string(b[:maxLoggedBodyBytes]) + "...(truncated)"
121-
}
122-
return string(b)
123-
}
124-
12551
// Console-owned fields we stash on the OpenAI batch.metadata map. Namespaced
12652
// to keep them out of user-supplied metadata's key space. The bare
12753
// "display_name" key is kept for backwards compatibility with batches
@@ -141,25 +67,15 @@ type JobHandler struct {
14167

14268
store store.Store
14369
planner plannerapi.Planner
144-
openai openai.Client
14570
defaultModelDeploymentTemplate string
14671
devMode bool
14772
}
14873

14974
// NewJobHandler creates a JobHandler.
150-
func NewJobHandler(s store.Store, planner plannerapi.Planner, metadataServiceURL, defaultModelDeploymentTemplate string, devMode bool) *JobHandler {
151-
152-
baseURL := strings.TrimRight(metadataServiceURL, "/") + "/v1"
153-
httpClient := &http.Client{Transport: &loggingTransport{base: http.DefaultTransport}}
154-
client := openai.NewClient(
155-
option.WithBaseURL(baseURL),
156-
option.WithAPIKey("aibrix-console"),
157-
option.WithHTTPClient(httpClient),
158-
)
75+
func NewJobHandler(s store.Store, planner plannerapi.Planner, defaultModelDeploymentTemplate string, devMode bool) *JobHandler {
15976
return &JobHandler{
16077
store: s,
16178
planner: planner,
162-
openai: client,
16379
defaultModelDeploymentTemplate: defaultModelDeploymentTemplate,
16480
devMode: devMode,
16581
}
@@ -193,8 +109,8 @@ func (h *JobHandler) ListJobs(ctx context.Context, req *pb.ListJobsRequest) (*pb
193109
}
194110

195111
jobs := make([]*pb.Job, 0, len(resp.Data))
196-
for _, batch := range resp.Data {
197-
jobs = append(jobs, mergeJob(batch, nil))
112+
for _, job := range resp.Data {
113+
jobs = append(jobs, mergeJob(job, nil))
198114
}
199115
// SDK CursorPage exposes Data and HasMore. first_id / last_id ride along
200116
// in the upstream JSON but are not surfaced as named fields; the UI
@@ -212,22 +128,22 @@ func (h *JobHandler) GetJob(ctx context.Context, req *pb.GetJobRequest) (*pb.Job
212128
if req.Id == "" {
213129
return nil, status.Error(codes.InvalidArgument, "id is required")
214130
}
215-
batch, err := h.planner.GetJob(ctx, req.Id)
131+
job, err := h.planner.GetJob(ctx, req.Id)
216132
if err != nil {
217133
// Dev fallback: return the demo job if MDS is unreachable.
218134
if h.devMode {
219135
if dev, ok := h.store.(interface {
220136
GetDemoJob(id string) (*pb.Job, bool)
221137
}); ok {
222-
if job, found := dev.GetDemoJob(req.Id); found {
138+
if demoJob, found := dev.GetDemoJob(req.Id); found {
223139
klog.Warningf("MDS unreachable, falling back to demo job %s: %v", req.Id, err)
224-
return job, nil
140+
return demoJob, nil
225141
}
226142
}
227143
}
228144
return nil, mapPlannerError(err, "get batch")
229145
}
230-
return mergeJob(batch, nil), nil
146+
return mergeJob(job, nil), nil
231147
}
232148

233149
// CreateJob calls POST /v1/batches with console-owned fields (display name,
@@ -253,6 +169,10 @@ func (h *JobHandler) CreateJob(ctx context.Context, req *pb.CreateJobRequest) (*
253169
completionWindow = string(openai.BatchNewParamsCompletionWindow24h)
254170
}
255171

172+
// Console-generated JobID. The queued planner will own a durable
173+
// JobID -> BatchID map; until then the planner keeps it in-memory.
174+
jobID := "job_" + uuid.NewString()
175+
256176
// Pack console-owned fields into batch.Metadata under the aibrix.console.*
257177
// namespace. This keeps a single source of truth (MDS) for the e2e demo
258178
// loop. The store-overlay path is parked (not deleted) for the future
@@ -289,32 +209,34 @@ func (h *JobHandler) CreateJob(ctx context.Context, req *pb.CreateJobRequest) (*
289209
}
290210

291211
enqueueReq := &plannerapi.EnqueueRequest{
212+
JobID: jobID,
292213
ModelTemplate: modelTemplate,
293-
BatchPayload: plannerapi.BatchPayload{
214+
BatchParams: openai.BatchNewParams{
294215
InputFileID: req.InputDataset,
295-
Endpoint: req.Endpoint,
296-
CompletionWindow: completionWindow,
216+
Endpoint: openai.BatchNewParamsEndpoint(req.Endpoint),
217+
CompletionWindow: openai.BatchNewParamsCompletionWindow(completionWindow),
297218
Metadata: metadata,
298219
},
299220
}
300221

301-
result, err := h.planner.Enqueue(ctx, enqueueReq)
222+
job, err := h.planner.Enqueue(ctx, enqueueReq)
302223
if err != nil {
303224
return nil, mapPlannerError(err, "create batch")
304225
}
305-
return mergeJob(result.Batch, nil), nil
226+
return mergeJob(job, nil), nil
306227
}
307228

308-
// CancelJob proxies to POST /v1/batches/{id}/cancel and merges with store.
229+
// CancelJob routes through Planner.Cancel; the planner resolves JobID
230+
// to MDS batch.ID and forwards to /v1/batches/{id}/cancel.
309231
func (h *JobHandler) CancelJob(ctx context.Context, req *pb.CancelJobRequest) (*pb.Job, error) {
310232
if req.Id == "" {
311233
return nil, status.Error(codes.InvalidArgument, "id is required")
312234
}
313-
batch, err := h.openai.Batches.Cancel(ctx, req.Id)
235+
job, err := h.planner.Cancel(ctx, req.Id)
314236
if err != nil {
315-
return nil, mapSDKError(err, "cancel batch")
237+
return nil, mapPlannerError(err, "cancel batch")
316238
}
317-
return mergeJob(batch, nil), nil
239+
return mergeJob(job, nil), nil
318240
}
319241

320242
// currentUserEmail returns the authenticated user's email if available, else
@@ -372,64 +294,67 @@ func mapPlannerError(err error, op string) error {
372294
return mapSDKError(err, op)
373295
}
374296

375-
// mergeJob aggregates the OpenAI Batch state with optional Console overlay.
376-
// Console-owned fields (display name, created_by, template binding) are read
377-
// out of batch.metadata under the aibrix.console.* namespace; the overlay
378-
// argument is plumbing for the future store-backed reconcile path and is
379-
// expected to be nil today.
380-
func mergeJob(b *openai.Batch, overlay *pb.Job) *pb.Job {
297+
// mergeJob aggregates the planner's Job with optional Console overlay.
298+
// pb.Job.Id is set to the planner's JobID — the MDS batch.ID never reaches
299+
// this layer. Console-owned fields (display name, created_by, template
300+
// binding) are read out of batch.metadata under the aibrix.console.*
301+
// namespace; the overlay argument is plumbing for the future store-backed
302+
// reconcile path and is expected to be nil today.
303+
func mergeJob(v *plannerapi.Job, overlay *pb.Job) *pb.Job {
381304
job := &pb.Job{}
382-
if b != nil {
383-
job.Id = b.ID
384-
job.Object = string(b.Object)
385-
job.Endpoint = b.Endpoint
386-
job.Model = b.Model
387-
job.InputDataset = b.InputFileID
388-
job.CompletionWindow = b.CompletionWindow
389-
job.Status = string(b.Status)
390-
job.OutputDataset = b.OutputFileID
391-
job.ErrorDataset = b.ErrorFileID
392-
job.CreatedAt = b.CreatedAt
393-
job.InProgressAt = b.InProgressAt
394-
job.ExpiresAt = b.ExpiresAt
395-
job.FinalizingAt = b.FinalizingAt
396-
job.CompletedAt = b.CompletedAt
397-
job.FailedAt = b.FailedAt
398-
job.ExpiredAt = b.ExpiredAt
399-
job.CancellingAt = b.CancellingAt
400-
job.CancelledAt = b.CancelledAt
401-
if len(b.Metadata) > 0 {
402-
job.Metadata = map[string]string(b.Metadata)
403-
// Console-owned fields. Prefer namespaced keys; fall back to the
404-
// legacy bare "display_name" so batches created by older builds
405-
// still surface their name.
406-
if v := b.Metadata[metadataConsoleDisplayName]; v != "" {
407-
job.Name = v
408-
} else if v := b.Metadata[metadataDisplayName]; v != "" {
409-
job.Name = v
410-
}
411-
if v := b.Metadata[metadataConsoleCreatedBy]; v != "" {
412-
job.CreatedBy = v
413-
}
414-
if v := b.Metadata[metadataConsoleTemplateName]; v != "" {
415-
job.ModelTemplateName = v
416-
}
417-
if v := b.Metadata[metadataConsoleTemplateVersion]; v != "" {
418-
job.ModelTemplateVersion = v
305+
if v != nil {
306+
job.Id = v.JobID
307+
if b := v.Batch; b != nil {
308+
job.Object = string(b.Object)
309+
job.Endpoint = b.Endpoint
310+
job.Model = b.Model
311+
job.InputDataset = b.InputFileID
312+
job.CompletionWindow = b.CompletionWindow
313+
job.Status = string(b.Status)
314+
job.OutputDataset = b.OutputFileID
315+
job.ErrorDataset = b.ErrorFileID
316+
job.CreatedAt = b.CreatedAt
317+
job.InProgressAt = b.InProgressAt
318+
job.ExpiresAt = b.ExpiresAt
319+
job.FinalizingAt = b.FinalizingAt
320+
job.CompletedAt = b.CompletedAt
321+
job.FailedAt = b.FailedAt
322+
job.ExpiredAt = b.ExpiredAt
323+
job.CancellingAt = b.CancellingAt
324+
job.CancelledAt = b.CancelledAt
325+
if len(b.Metadata) > 0 {
326+
job.Metadata = map[string]string(b.Metadata)
327+
// Console-owned fields. Prefer namespaced keys; fall back to the
328+
// legacy bare "display_name" so batches created by older builds
329+
// still surface their name.
330+
if v := b.Metadata[metadataConsoleDisplayName]; v != "" {
331+
job.Name = v
332+
} else if v := b.Metadata[metadataDisplayName]; v != "" {
333+
job.Name = v
334+
}
335+
if v := b.Metadata[metadataConsoleCreatedBy]; v != "" {
336+
job.CreatedBy = v
337+
}
338+
if v := b.Metadata[metadataConsoleTemplateName]; v != "" {
339+
job.ModelTemplateName = v
340+
}
341+
if v := b.Metadata[metadataConsoleTemplateVersion]; v != "" {
342+
job.ModelTemplateVersion = v
343+
}
419344
}
420-
}
421-
if b.JSON.RequestCounts.Valid() {
422-
job.RequestCounts = &pb.JobRequestCounts{
423-
Total: int32(b.RequestCounts.Total),
424-
Completed: int32(b.RequestCounts.Completed),
425-
Failed: int32(b.RequestCounts.Failed),
345+
if b.JSON.RequestCounts.Valid() {
346+
job.RequestCounts = &pb.JobRequestCounts{
347+
Total: int32(b.RequestCounts.Total),
348+
Completed: int32(b.RequestCounts.Completed),
349+
Failed: int32(b.RequestCounts.Failed),
350+
}
426351
}
427-
}
428-
if b.JSON.Usage.Valid() {
429-
job.Usage = &pb.JobUsage{
430-
InputTokens: b.Usage.InputTokens,
431-
OutputTokens: b.Usage.OutputTokens,
432-
TotalTokens: b.Usage.TotalTokens,
352+
if b.JSON.Usage.Valid() {
353+
job.Usage = &pb.JobUsage{
354+
InputTokens: b.Usage.InputTokens,
355+
OutputTokens: b.Usage.OutputTokens,
356+
TotalTokens: b.Usage.TotalTokens,
357+
}
433358
}
434359
}
435360
}

apps/console/api/planner/api/errors.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,4 +31,10 @@ var (
3131
// RM package) is wrapped with this sentinel so the planner stays
3232
// decoupled from the concrete RM error vocabulary.
3333
ErrInsufficientResources = errors.New("planner: insufficient resources")
34+
35+
// ErrJobNotFound indicates the JobID passed to GetJob/Cancel is not
36+
// known to the planner. With Passthrough's in-memory map this fires
37+
// for jobs created before the process started; the queued planner's
38+
// durable index will narrow this to "truly unknown JobID" cases.
39+
ErrJobNotFound = errors.New("planner: job not found")
3440
)

0 commit comments

Comments
 (0)