feat(worker): 实现集群限流与自适应负载
保留平台模型 RPM、TPM 和并发策略语义,增加 PostgreSQL 集群级租约、饱和候选重选和多平台自动负载,避免突发任务固定等待首个平台。\n\n新增 Worker 实时负载采样、自适应 active/heavy 容量、心跳与管理端指标,并扩展本地 acceptance runner,覆盖三 Worker、同模型三平台 2/4/6 并发和 48 个带图视频突发任务。\n\n验证:go test ./...、go vet ./...、PostgreSQL 跨 Store 集成测试、gofmt、bash -n、ShellCheck 及本地集群 provider-burst 验收通过;48/48 成功,无越限、重复提交、重复计费、重复回调或终态资源泄漏。
This commit is contained in:
@@ -59,6 +59,7 @@ type Server struct {
|
||||
type Report struct {
|
||||
GeminiRequests int64 `json:"geminiRequests"`
|
||||
GeminiInvalid int64 `json:"geminiInvalid"`
|
||||
GeminiInputImages int64 `json:"geminiInputImages"`
|
||||
GeminiInputBytes int64 `json:"geminiInputBytes"`
|
||||
GeminiOutputBytes int64 `json:"geminiOutputBytes"`
|
||||
VideoSubmissions int64 `json:"videoSubmissions"`
|
||||
@@ -163,7 +164,7 @@ func (s *Server) geminiGenerateContent(w http.ResponseWriter, r *http.Request) {
|
||||
writeProtocolError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
inputBytes, err := validateGeminiImageRequest(body)
|
||||
inputBytes, inputImages, err := validateGeminiImageRequest(body)
|
||||
if err != nil {
|
||||
s.recordGeminiInvalid()
|
||||
writeProtocolError(w, http.StatusBadRequest, err.Error())
|
||||
@@ -180,6 +181,7 @@ func (s *Server) geminiGenerateContent(w http.ResponseWriter, r *http.Request) {
|
||||
s.geminiIdempotency[idempotencyKey] = struct{}{}
|
||||
}
|
||||
s.report.GeminiRequests++
|
||||
s.report.GeminiInputImages += int64(inputImages)
|
||||
s.report.GeminiInputBytes += int64(inputBytes)
|
||||
s.report.GeminiOutputBytes += int64(outputBytes)
|
||||
s.mu.Unlock()
|
||||
@@ -533,7 +535,7 @@ func decodeImageDataURL(raw string) ([]byte, error) {
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
func validateGeminiImageRequest(body map[string]any) (int, error) {
|
||||
func validateGeminiImageRequest(body map[string]any) (int, int, error) {
|
||||
generationConfig, _ := body["generationConfig"].(map[string]any)
|
||||
modalities, _ := generationConfig["responseModalities"].([]any)
|
||||
hasImageModality := false
|
||||
@@ -543,9 +545,10 @@ func validateGeminiImageRequest(body map[string]any) (int, error) {
|
||||
}
|
||||
}
|
||||
if !hasImageModality {
|
||||
return 0, errors.New("generationConfig.responseModalities must contain IMAGE")
|
||||
return 0, 0, errors.New("generationConfig.responseModalities must contain IMAGE")
|
||||
}
|
||||
total := 0
|
||||
images := 0
|
||||
contents, _ := body["contents"].([]any)
|
||||
for _, rawContent := range contents {
|
||||
content, _ := rawContent.(map[string]any)
|
||||
@@ -558,22 +561,41 @@ func validateGeminiImageRequest(body map[string]any) (int, error) {
|
||||
}
|
||||
encoded := strings.TrimSpace(stringValue(inline["data"]))
|
||||
if encoded == "" {
|
||||
fileData, _ := part["fileData"].(map[string]any)
|
||||
if fileData == nil {
|
||||
fileData, _ = part["file_data"].(map[string]any)
|
||||
}
|
||||
fileURI := strings.TrimSpace(stringValue(fileData["fileUri"]))
|
||||
if fileURI == "" {
|
||||
fileURI = strings.TrimSpace(stringValue(fileData["file_uri"]))
|
||||
}
|
||||
if fileURI == "" {
|
||||
fileURI = strings.TrimSpace(stringValue(fileData["uri"]))
|
||||
}
|
||||
if fileURI == "" {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(fileURI, "http://") && !strings.HasPrefix(fileURI, "https://") {
|
||||
return 0, 0, errors.New("Gemini fileData URI must use HTTP or HTTPS")
|
||||
}
|
||||
images++
|
||||
continue
|
||||
}
|
||||
decoded, err := base64.StdEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("Gemini inlineData is not valid Base64: %w", err)
|
||||
return 0, 0, fmt.Errorf("Gemini inlineData is not valid Base64: %w", err)
|
||||
}
|
||||
if len(decoded) == 0 {
|
||||
return 0, errors.New("Gemini inlineData is empty")
|
||||
return 0, 0, errors.New("Gemini inlineData is empty")
|
||||
}
|
||||
total += len(decoded)
|
||||
images++
|
||||
}
|
||||
}
|
||||
if total == 0 {
|
||||
return 0, errors.New("Gemini request has no inlineData image")
|
||||
if images == 0 {
|
||||
return 0, 0, errors.New("Gemini request has no inlineData or fileData image")
|
||||
}
|
||||
return total, nil
|
||||
return total, images, nil
|
||||
}
|
||||
|
||||
func (s *Server) recordGeminiInvalid() {
|
||||
|
||||
@@ -155,6 +155,44 @@ func TestForcedConversionRejectsImagesOutsideOfficialSeedanceRange(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeminiProtocolAcceptsMultipleFileDataImages(t *testing.T) {
|
||||
server := New(Config{Wait: func(context.Context, time.Duration) error { return nil }})
|
||||
httpServer := httptest.NewServer(server.Handler())
|
||||
defer httpServer.Close()
|
||||
parts := []any{map[string]any{"text": "combine references"}}
|
||||
for index := 0; index < 3; index++ {
|
||||
parts = append(parts, map[string]any{"fileData": map[string]any{
|
||||
"mimeType": "image/png",
|
||||
"fileUri": fmt.Sprintf("https://fixtures.example/%d.png", index),
|
||||
}})
|
||||
}
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"contents": []any{map[string]any{"parts": parts}},
|
||||
"generationConfig": map[string]any{"responseModalities": []any{"IMAGE"}},
|
||||
})
|
||||
response, err := postIdempotent(httpServer.URL+"/v1beta/models/gemini-test:generateContent", body, "gemini-multi")
|
||||
if err != nil {
|
||||
t.Fatalf("Gemini multi-image request: %v", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
payload, _ := io.ReadAll(response.Body)
|
||||
t.Fatalf("Gemini multi-image status=%d body=%s", response.StatusCode, payload)
|
||||
}
|
||||
reportResponse, err := http.Get(httpServer.URL + "/report")
|
||||
if err != nil {
|
||||
t.Fatalf("get report: %v", err)
|
||||
}
|
||||
defer reportResponse.Body.Close()
|
||||
var report Report
|
||||
if err := json.NewDecoder(reportResponse.Body).Decode(&report); err != nil {
|
||||
t.Fatalf("decode report: %v", err)
|
||||
}
|
||||
if report.GeminiRequests != 1 || report.GeminiInputImages != 3 || report.GeminiInvalid != 0 {
|
||||
t.Fatalf("unexpected multi-image report: %+v", report)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageFixturesFullyDecode(t *testing.T) {
|
||||
for name, fixture := range buildFixtures() {
|
||||
if !strings.HasPrefix(fixture.ContentType, "image/") {
|
||||
|
||||
Reference in New Issue
Block a user