单二进制三角色 + Dock 适配器 + Swarm stack 达到可部署态;mgr1 实测订阅经 central-proxy bootstrap,探活 alive=41/52。 Co-authored-by: Cursor <cursoragent@cursor.com>
186 lines
6.1 KiB
Go
186 lines
6.1 KiB
Go
// schedulerclient.go:gateway → scheduler 内部 HTTP 契约客户端。
|
||
//
|
||
// 内部契约(W2/W3 对齐面,详见 impl-receipt-w2 §4):
|
||
//
|
||
// POST {base}/enqueue body=contract.JobEnvelopeExt → 200 {ok,job_id,request_id,queued_position}
|
||
// 失败:503 {error:{code:"unavailable"}} / 429 {error:{code:"rate_limited"}}
|
||
// GET {base}/result/{request_id} → 200 {request_id,status,position?,envelope?}
|
||
//
|
||
// 失败语义:连接失败/5xx 一律返回 *APIError(gateway 转 503+Retry-After,零落盘)。
|
||
package gateway
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"strings"
|
||
"time"
|
||
|
||
"onesvm.com/onesvm/browser-server/internal/contract"
|
||
)
|
||
|
||
// EnqueueTimeout enqueue 单次请求超时(brief:3s)。
|
||
const EnqueueTimeout = 3 * time.Second
|
||
|
||
// APIError scheduler 侧错误(HTTP 状态 + 错误码)。
|
||
type APIError struct {
|
||
HTTPStatus int
|
||
Code string
|
||
Message string
|
||
}
|
||
|
||
// Error 实现 error。
|
||
func (e *APIError) Error() string {
|
||
return fmt.Sprintf("scheduler: %d %s: %s", e.HTTPStatus, e.Code, e.Message)
|
||
}
|
||
|
||
// SchedulerClient scheduler HTTP 客户端(enqueue 3s 超时 + 1 次重试;result 轮询)。
|
||
type SchedulerClient struct {
|
||
base string
|
||
hc *http.Client
|
||
}
|
||
|
||
// NewSchedulerClient 构造。base 形如 http://scheduler:8641。
|
||
func NewSchedulerClient(base string) *SchedulerClient {
|
||
base = strings.TrimRight(base, "/")
|
||
return &SchedulerClient{
|
||
base: base,
|
||
hc: &http.Client{
|
||
Timeout: EnqueueTimeout,
|
||
Transport: &http.Transport{
|
||
MaxIdleConns: 8,
|
||
IdleConnTimeout: 30 * time.Second,
|
||
ResponseHeaderTimeout: EnqueueTimeout,
|
||
},
|
||
},
|
||
}
|
||
}
|
||
|
||
// Enqueue 入队(3s 超时 + 1 次重试)。重试仅针对网络错误/503(幂等:request_id 唯一约束兜底)。
|
||
func (c *SchedulerClient) Enqueue(ctx context.Context, job *contract.JobEnvelopeExt) (*enqueueResult, error) {
|
||
body, err := json.Marshal(job)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("gateway: job 序列化: %w", err)
|
||
}
|
||
var lastErr error
|
||
for attempt := 0; attempt < 2; attempt++ {
|
||
if attempt > 0 {
|
||
select {
|
||
case <-ctx.Done():
|
||
return nil, ctx.Err()
|
||
case <-time.After(200 * time.Millisecond):
|
||
}
|
||
}
|
||
res, err := c.postEnqueue(ctx, body)
|
||
if err == nil {
|
||
return res, nil
|
||
}
|
||
lastErr = err
|
||
// 4xx(除 503/429 限流类)不重试:请求本身有问题
|
||
if ae, ok := err.(*APIError); ok && ae.HTTPStatus != http.StatusServiceUnavailable &&
|
||
ae.HTTPStatus != http.StatusTooManyRequests {
|
||
return nil, err
|
||
}
|
||
}
|
||
return nil, lastErr
|
||
}
|
||
|
||
// postEnqueue 单次 enqueue 调用。
|
||
func (c *SchedulerClient) postEnqueue(ctx context.Context, body []byte) (*enqueueResult, error) {
|
||
cctx, cancel := context.WithTimeout(ctx, EnqueueTimeout)
|
||
defer cancel()
|
||
req, err := http.NewRequestWithContext(cctx, http.MethodPost, c.base+"/enqueue", bytes.NewReader(body))
|
||
if err != nil {
|
||
return nil, fmt.Errorf("gateway: enqueue 请求构造: %w", err)
|
||
}
|
||
req.Header.Set("Content-Type", "application/json")
|
||
resp, err := c.hc.Do(req)
|
||
if err != nil {
|
||
return nil, &APIError{HTTPStatus: http.StatusServiceUnavailable,
|
||
Code: contract.CodeUnavailable, Message: "scheduler 不可达"}
|
||
}
|
||
defer resp.Body.Close()
|
||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
|
||
switch {
|
||
case resp.StatusCode == http.StatusOK:
|
||
var res enqueueResult
|
||
if err := json.Unmarshal(raw, &res); err != nil {
|
||
return nil, fmt.Errorf("gateway: enqueue 响应解析: %w", err)
|
||
}
|
||
return &res, nil
|
||
case resp.StatusCode == http.StatusServiceUnavailable || resp.StatusCode == http.StatusTooManyRequests:
|
||
ae := &APIError{HTTPStatus: resp.StatusCode, Code: contract.CodeUnavailable,
|
||
Message: "scheduler 队列满"}
|
||
var eb struct {
|
||
Error *contract.ErrBody `json:"error"`
|
||
}
|
||
if json.Unmarshal(raw, &eb) == nil && eb.Error != nil {
|
||
ae.Code = eb.Error.Code
|
||
ae.Message = eb.Error.Message
|
||
}
|
||
return nil, ae
|
||
default:
|
||
return nil, &APIError{HTTPStatus: resp.StatusCode, Code: contract.CodeUpstream,
|
||
Message: fmt.Sprintf("enqueue 异常状态 %d", resp.StatusCode)}
|
||
}
|
||
}
|
||
|
||
// Result 查询任务状态(轮询路径;短超时防轮询拖垮)。
|
||
func (c *SchedulerClient) Result(ctx context.Context, requestID string) (*resultState, error) {
|
||
cctx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
||
defer cancel()
|
||
req, err := http.NewRequestWithContext(cctx, http.MethodGet,
|
||
c.base+"/result/"+requestID, nil)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("gateway: result 请求构造: %w", err)
|
||
}
|
||
resp, err := c.hc.Do(req)
|
||
if err != nil {
|
||
// 轮询途中的瞬时不可达:让上层重试下一跳,不立即判死
|
||
return nil, &APIError{HTTPStatus: http.StatusServiceUnavailable,
|
||
Code: contract.CodeUnavailable, Message: "scheduler 不可达"}
|
||
}
|
||
defer resp.Body.Close()
|
||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 16*1024*1024))
|
||
// 202 = scheduler 侧 queued/running 非终态(ITER-1 F1 统一形状,golden:
|
||
// contract.ResultShapeAccepted):解析 status 返回,由 waitResult 继续轮询。
|
||
if resp.StatusCode == http.StatusAccepted {
|
||
var st resultState
|
||
if err := json.Unmarshal(raw, &st); err != nil {
|
||
return nil, fmt.Errorf("gateway: result 202 响应解析: %w", err)
|
||
}
|
||
return &st, nil
|
||
}
|
||
if resp.StatusCode != http.StatusOK {
|
||
return nil, &APIError{HTTPStatus: resp.StatusCode, Code: contract.CodeUpstream,
|
||
Message: fmt.Sprintf("result 异常状态 %d", resp.StatusCode)}
|
||
}
|
||
var st resultState
|
||
if err := json.Unmarshal(raw, &st); err != nil {
|
||
return nil, fmt.Errorf("gateway: result 响应解析: %w", err)
|
||
}
|
||
return &st, nil
|
||
}
|
||
|
||
// Pressure 查询背压(/readyz 用)。
|
||
func (c *SchedulerClient) Pressure(ctx context.Context) error {
|
||
cctx, cancel := context.WithTimeout(ctx, 2*time.Second)
|
||
defer cancel()
|
||
req, err := http.NewRequestWithContext(cctx, http.MethodGet, c.base+"/pressure", nil)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
resp, err := c.hc.Do(req)
|
||
if err != nil {
|
||
return fmt.Errorf("scheduler /pressure 不可达: %w", err)
|
||
}
|
||
defer resp.Body.Close()
|
||
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4*1024))
|
||
if resp.StatusCode != http.StatusOK {
|
||
return fmt.Errorf("scheduler /pressure 状态 %d", resp.StatusCode)
|
||
}
|
||
return nil
|
||
}
|