Skip to content

Commit 201ef7f

Browse files
committed
address-haiyang-review
Signed-off-by: Ning Wang <n.wang.chn@hotmail.com>
1 parent fd956d6 commit 201ef7f

4 files changed

Lines changed: 163 additions & 44 deletions

File tree

apps/console/api/planner/client/submission.go

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,22 @@ import (
2525
// because its shape will evolve as the RM populates per-group allocation
2626
// details (currently always nil — RM doesn't return them yet).
2727
type PlannerDecision struct {
28-
ProvisionID string `json:"provision_id,omitempty"`
29-
ProvisionResourceDeadline int64 `json:"provision_resource_deadline,omitempty"`
28+
// ProvisionID mirrors rmtypes.ProvisionResult.ProvisionID returned
29+
// by Provisioner.Provision.
30+
ProvisionID string `json:"provision_id,omitempty"`
31+
// ProvisionResourceDeadline is the unix-seconds deadline.
32+
ProvisionResourceDeadline int64 `json:"provision_resource_deadline,omitempty"`
3033
ResourceDetails []struct {
31-
ResourceType string `json:"resource_type"`
34+
// ResourceType maps to rmtypes.ResourceProvisionType
35+
// (kubernetes / aws / lambdaCloud); may grow finer-grained.
36+
ResourceType string `json:"resource_type"`
37+
// EndpointCluster identifies the cluster serving this group;
3238
EndpointCluster string `json:"endpoint_cluster,omitempty"`
33-
GPUType string `json:"gpu_type,omitempty"`
34-
WorkerNum int `json:"worker_num,omitempty"`
39+
// GPUType identifies the GPU model of the provisioned nodes.
40+
// The GPU count is not defined here; it comes from the ModelTemplate.
41+
GPUType string `json:"gpu_type,omitempty"`
42+
// WorkerNum identifies the number of replicas to provision
43+
WorkerNum int `json:"worker_num,omitempty"`
3544
} `json:"resource_details,omitempty"`
3645
}
3746

apps/console/api/planner/impl/planner.go

Lines changed: 41 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ type Planner struct {
4141
bc plannerclient.BatchClient
4242
prov provisioner.Provisioner
4343

44-
submit chan string // buffered FIFO of pending JobIDs
44+
queue pendingQueue
4545

4646
baseCtx context.Context
4747
baseCancel context.CancelFunc
@@ -60,7 +60,7 @@ type Planner struct {
6060
// it to "pending" or "provisioning". After submission, status comes from
6161
// MDS.
6262
//
63-
// Pending → buffered in submit channel, no worker yet.
63+
// Pending → buffered in the queue, no worker yet.
6464
// Provisioning → worker holds the job; Provision + CreateBatch in flight.
6565
// Submitted → CreateBatch returned; MDS owns the lifecycle from here.
6666
// Failed → Provision or CreateBatch errored.
@@ -79,13 +79,14 @@ const (
7979
)
8080

8181
type queuedJob struct {
82-
req *plannerapi.EnqueueRequest
83-
state jobState
84-
batchID string // populated when state == jobStateSubmitted
85-
err error // populated when state == jobStateFailed
86-
enqueuedAt time.Time
87-
failedAt time.Time // populated when state == jobStateFailed
88-
canceledAt time.Time // populated when state == jobStateCanceled
82+
req *plannerapi.EnqueueRequest
83+
state jobState
84+
provisionID string // populated once Provision returns accepted
85+
batchID string // populated when state == jobStateSubmitted
86+
err error // populated when state == jobStateFailed
87+
enqueuedAt time.Time
88+
failedAt time.Time // populated when state == jobStateFailed
89+
canceledAt time.Time // populated when state == jobStateCanceled
8990
}
9091

9192
// terminalTime returns the timestamp at which the job transitioned into a
@@ -101,10 +102,6 @@ func terminalTime(j *queuedJob) time.Time {
101102
return time.Time{}
102103
}
103104

104-
// queueCapacity caps the submit channel. When full, Enqueue blocks on the
105-
// caller's context.
106-
const queueCapacity = 256
107-
108105
// DefaultWorkerCount sizes the worker pool.
109106
const DefaultWorkerCount = 8
110107

@@ -127,7 +124,7 @@ func NewPlanner(bc plannerclient.BatchClient, prov provisioner.Provisioner, work
127124
q := &Planner{
128125
bc: bc,
129126
prov: prov,
130-
submit: make(chan string, queueCapacity),
127+
queue: newFIFOPendingQueue(queueCapacity),
131128
baseCtx: ctx,
132129
baseCancel: cancel,
133130
jobs: make(map[string]*queuedJob),
@@ -146,6 +143,7 @@ var _ plannerapi.Planner = (*Planner)(nil)
146143

147144
// Close cancels in-flight work and waits for workers to exit.
148145
func (q *Planner) Close() error {
146+
q.queue.Close()
149147
q.baseCancel()
150148
q.wg.Wait()
151149
return nil
@@ -154,12 +152,11 @@ func (q *Planner) Close() error {
154152
func (q *Planner) run() {
155153
defer q.wg.Done()
156154
for {
157-
select {
158-
case <-q.baseCtx.Done():
155+
jobID, err := q.queue.Pop(q.baseCtx)
156+
if err != nil {
159157
return
160-
case jobID := <-q.submit:
161-
q.process(jobID)
162158
}
159+
q.process(jobID)
163160
}
164161
}
165162

@@ -171,11 +168,12 @@ func (q *Planner) process(jobID string) {
171168
q.mu.Unlock()
172169
return
173170
}
171+
// Example: a pending job is canceled before a worker picks it up, so the
172+
// worker later observes a non-pending state here and skips provisioning.
174173
if job.state != jobStatePending {
175-
// Cancel raced ahead; drop without provisioning.
176174
state := job.state
177175
q.mu.Unlock()
178-
klog.Infof("[planner] skip job_id=%q state=%d", jobID, state)
176+
klog.Infof("[planner] invalid state before provisioning job_id=%q state=%d", jobID, state)
179177
return
180178
}
181179
job.state = jobStateProvisioning
@@ -193,6 +191,9 @@ func (q *Planner) process(jobID string) {
193191
q.markFailed(jobID, errors.Join(plannerapi.ErrInsufficientResources, err))
194192
return
195193
}
194+
q.mu.Lock()
195+
q.jobs[jobID].provisionID = provResult.ProvisionID
196+
q.mu.Unlock()
196197

197198
// Provision returns when the request is accepted, not when the resource
198199
// is ready. Wait for Running before submitting to MDS, which rejects
@@ -284,8 +285,9 @@ func (q *Planner) waitForProvisionReady(provisionID string) error {
284285
}
285286

286287
// releaseAfter performs a best-effort RM release and logs failures. The
287-
// reason string ("wait failure", "CreateBatch failure", "cancel-race")
288-
// appears in the log line so each call site is self-identifying.
288+
// reason string ("wait failure", "CreateBatch failure", "cancel-race",
289+
// "cancel submitted") appears in the log line so each call site is
290+
// self-identifying.
289291
func (q *Planner) releaseAfter(jobID, provisionID, reason string) {
290292
if err := q.prov.Release(q.baseCtx, provisionID); err != nil {
291293
klog.Warningf("[planner] release after %s job_id=%q provision_id=%q: %v",
@@ -295,16 +297,15 @@ func (q *Planner) releaseAfter(jobID, provisionID, reason string) {
295297

296298
func (q *Planner) markFailed(jobID string, err error) {
297299
q.mu.Lock()
298-
if job, ok := q.jobs[jobID]; ok {
299-
job.state = jobStateFailed
300-
job.err = err
301-
job.failedAt = time.Now()
302-
}
300+
job := q.jobs[jobID]
301+
job.state = jobStateFailed
302+
job.err = err
303+
job.failedAt = time.Now()
303304
q.mu.Unlock()
304305
klog.Warningf("[planner] job_id=%q failed: %v", jobID, err)
305306
}
306307

307-
// Enqueue records the job, pushes it onto the worker channel, and returns
308+
// Enqueue records the job, pushes it onto the queue, and returns
308309
// a placeholder batch in "pending" status.
309310
func (q *Planner) Enqueue(ctx context.Context, req *plannerapi.EnqueueRequest) (*plannerapi.Job, error) {
310311
if req == nil {
@@ -322,6 +323,9 @@ func (q *Planner) Enqueue(ctx context.Context, req *plannerapi.EnqueueRequest) (
322323
if q.prov == nil {
323324
return nil, fmt.Errorf("%w: missing provisioner", plannerapi.ErrInsufficientResources)
324325
}
326+
if err := q.baseCtx.Err(); err != nil {
327+
return nil, fmt.Errorf("planner closed: %w", err)
328+
}
325329

326330
now := time.Now()
327331
q.mu.Lock()
@@ -336,17 +340,14 @@ func (q *Planner) Enqueue(ctx context.Context, req *plannerapi.EnqueueRequest) (
336340
}
337341
q.mu.Unlock()
338342

339-
select {
340-
case q.submit <- req.JobID:
341-
// Happy path: a worker will dequeue and drive the entry; nothing to roll back.
342-
case <-ctx.Done():
343-
// Caller gave up while q.submit was full; the bookkeeping insert is orphaned.
343+
if err := q.queue.Push(ctx, req.JobID); err != nil {
344344
q.rollbackEnqueue(req.JobID)
345-
return nil, ctx.Err()
346-
case <-q.baseCtx.Done():
347-
// Planner shutting down while q.submit was full; roll back the orphaned insert.
348-
q.rollbackEnqueue(req.JobID)
349-
return nil, fmt.Errorf("planner closed: %w", q.baseCtx.Err())
345+
if errors.Is(err, errQueueClosed) {
346+
// Planner shutting down while the queue was full; roll back the orphaned insert.
347+
return nil, fmt.Errorf("planner closed: %w", q.baseCtx.Err())
348+
}
349+
// Caller gave up while the queue was full; the bookkeeping insert is orphaned.
350+
return nil, err
350351
}
351352

352353
klog.Infof("[planner] enqueue job_id=%q", req.JobID)
@@ -405,6 +406,7 @@ func (q *Planner) Cancel(ctx context.Context, jobID string) (*plannerapi.Job, er
405406
}
406407
state := job.state
407408
batchID := job.batchID
409+
provisionID := job.provisionID
408410
req := job.req
409411
enqueuedAt := job.enqueuedAt
410412
var terminalAt time.Time
@@ -428,6 +430,7 @@ func (q *Planner) Cancel(ctx context.Context, jobID string) (*plannerapi.Job, er
428430
if err != nil {
429431
return nil, err
430432
}
433+
q.releaseAfter(jobID, provisionID, "cancel submitted")
431434
return &plannerapi.Job{JobID: jobID, Batch: batch}, nil
432435
}
433436
// Already terminal (failed/canceled) — return current view, no double-cancel side effects.

apps/console/api/planner/impl/planner_test.go

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -711,7 +711,7 @@ func TestCancelQueuedJobBeforeWorkerPicksUp(t *testing.T) {
711711
}
712712
}
713713

714-
func TestCancelSubmittedJobForwardsToMDS(t *testing.T) {
714+
func TestCancelSubmittedJobForwardsToMDSAndReleasesProvision(t *testing.T) {
715715
prov := &fakeProvisioner{}
716716
bc := &fakeBatchClient{}
717717
q := newTestPlanner(t, bc, prov, 1)
@@ -737,6 +737,11 @@ func TestCancelSubmittedJobForwardsToMDS(t *testing.T) {
737737
if len(cancels) != 1 || cancels[0] != "batch-j-sub" {
738738
t.Errorf("bc.CancelBatch calls = %v; want [batch-j-sub]", cancels)
739739
}
740+
741+
_, releases, _ := prov.snapshot()
742+
if len(releases) != 1 || releases[0] != "prov-j-sub" {
743+
t.Errorf("prov.Release calls = %v; want [prov-j-sub]", releases)
744+
}
740745
}
741746

742747
func TestCancelUnknownJobReturnsNotFound(t *testing.T) {
@@ -910,6 +915,27 @@ func TestCloseIsIdempotent(t *testing.T) {
910915
}
911916
}
912917

918+
// TestEnqueueAfterCloseReturnsClosed locks down planner shutdown semantics:
919+
// once Close returns, new Enqueue calls must fail immediately instead of
920+
// slipping into the buffered pending queue.
921+
func TestEnqueueAfterCloseReturnsClosed(t *testing.T) {
922+
q := NewPlanner(&fakeBatchClient{}, &fakeProvisioner{}, 1)
923+
if err := q.Close(); err != nil {
924+
t.Fatalf("Close: %v", err)
925+
}
926+
927+
// This guards the refactor from queue-owned shutdown to planner-owned
928+
// shutdown checks. Without the Enqueue-side closed check, the buffered
929+
// queue can still accept a job after Close.
930+
_, err := q.Enqueue(context.Background(), validReq("j-closed"))
931+
if err == nil {
932+
t.Fatal("Enqueue after Close unexpectedly succeeded")
933+
}
934+
if !errors.Is(err, context.Canceled) {
935+
t.Fatalf("want Close error to wrap context.Canceled; got %v", err)
936+
}
937+
}
938+
913939
// TestWorkerCountFloor: a non-positive workerCount must be floored to 1
914940
// rather than starting zero goroutines (which would silently hang every
915941
// Enqueue forever).
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
/*
2+
Copyright 2026 The Aibrix Team.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package impl
18+
19+
import (
20+
"context"
21+
"errors"
22+
"sync"
23+
)
24+
25+
var errQueueClosed = errors.New("queue closed")
26+
27+
const queueCapacity = 256
28+
29+
// pendingQueue is the planner's buffer of pending job IDs.
30+
type pendingQueue interface {
31+
// Push returns ctx.Err() on caller cancel, errQueueClosed on shutdown.
32+
Push(ctx context.Context, jobID string) error
33+
// Pop returns ctx.Err() on caller cancel, errQueueClosed on shutdown.
34+
Pop(ctx context.Context) (string, error)
35+
Close()
36+
}
37+
38+
// fifoPendingQueue preserves enqueue order for pending job IDs.
39+
type fifoPendingQueue struct {
40+
ch chan string
41+
done chan struct{}
42+
closeOnce sync.Once
43+
}
44+
45+
func newFIFOPendingQueue(capacity int) *fifoPendingQueue {
46+
if capacity < 1 {
47+
capacity = 1
48+
}
49+
return &fifoPendingQueue{
50+
ch: make(chan string, capacity),
51+
done: make(chan struct{}),
52+
}
53+
}
54+
55+
func (q *fifoPendingQueue) Push(ctx context.Context, jobID string) error {
56+
select {
57+
case q.ch <- jobID:
58+
return nil
59+
case <-ctx.Done():
60+
return ctx.Err()
61+
case <-q.done:
62+
return errQueueClosed
63+
}
64+
}
65+
66+
func (q *fifoPendingQueue) Pop(ctx context.Context) (string, error) {
67+
select {
68+
case jobID := <-q.ch:
69+
return jobID, nil
70+
case <-ctx.Done():
71+
return "", ctx.Err()
72+
case <-q.done:
73+
return "", errQueueClosed
74+
}
75+
}
76+
77+
func (q *fifoPendingQueue) Close() {
78+
q.closeOnce.Do(func() {
79+
close(q.done)
80+
})
81+
}

0 commit comments

Comments
 (0)