perf(storage): 拦截任务二进制并本地暂存结果
原因:任务标准结果中的 Base64、Data URI 和 Buffer 会进入 PostgreSQL JSON,导致 TOAST 与备份体积快速增长。 影响:新增统一 JSON 持久化关口;upload_none 将二进制原子写入本地结果目录,数据库仅保存带 SHA-256 的有界占位符;任务详情、同步响应、异步查询和兼容协议按需校验恢复。补充 24 小时清理、容量上限、历史小批量治理命令及管理端说明。 风险:本地结果超过 TTL、丢失或损坏时分别返回明确的 410/500;空间不足时返回 503 且不重试上游。未自动执行历史治理。 验证:三种真实图片模型同步/异步与幂等重放通过;Go vet/全量测试、前端 111 测试、lint/typecheck/build、OpenAPI、迁移安全、govulncheck、依赖审计、手工发布测试及 Linux amd64 构建通过。
This commit is contained in:
@@ -0,0 +1,233 @@
|
||||
package runner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/clients"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/config"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
func newLocalBinaryTestService(t *testing.T) *Service {
|
||||
t.Helper()
|
||||
return &Service{cfg: config.Config{
|
||||
LocalGeneratedStorageDir: t.TempDir(),
|
||||
LocalResultTTLHours: 24,
|
||||
LocalResultMinFreeBytes: 1,
|
||||
LocalResultMaxBytes: 1024 * 1024,
|
||||
LocalResultMaxTaskBytes: 2 * 1024 * 1024,
|
||||
}}
|
||||
}
|
||||
|
||||
func TestMaterializeAndHydrateLocalBinaryResult(t *testing.T) {
|
||||
service := newLocalBinaryTestService(t)
|
||||
payload := []byte("one binary result shared across representations")
|
||||
encoded := base64.StdEncoding.EncodeToString(payload)
|
||||
input := map[string]any{
|
||||
"data": []any{
|
||||
map[string]any{
|
||||
"b64_json": encoded,
|
||||
"data_uri": "data:image/png;base64," + encoded,
|
||||
"buffer": map[string]any{
|
||||
"type": "Buffer",
|
||||
"data": bytesToAny(payload),
|
||||
"mimeType": "image/png",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
persistent, changed, err := service.materializeLocalBinaryResult(context.Background(), "task-123", input)
|
||||
if err != nil {
|
||||
t.Fatalf("materialize local binary result: %v", err)
|
||||
}
|
||||
if !changed {
|
||||
t.Fatal("expected binary result to be materialized")
|
||||
}
|
||||
item := persistent["data"].([]any)[0].(map[string]any)
|
||||
for _, key := range []string{"b64_json", "data_uri", "buffer"} {
|
||||
placeholder, ok := item[key].(string)
|
||||
if !ok || !strings.HasPrefix(placeholder, localBinaryPlaceholderPrefix) {
|
||||
t.Fatalf("%s was not replaced with a placeholder: %+v", key, item[key])
|
||||
}
|
||||
if len(placeholder) > 200 {
|
||||
t.Fatalf("%s placeholder exceeds 200 bytes: %d", key, len(placeholder))
|
||||
}
|
||||
}
|
||||
if input["data"].([]any)[0].(map[string]any)["b64_json"] != encoded {
|
||||
t.Fatal("materializer mutated the provider result")
|
||||
}
|
||||
persistentJSON, err := json.Marshal(persistent)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal persistent result: %v", err)
|
||||
}
|
||||
if len(persistentJSON) > 32*1024 {
|
||||
t.Fatalf("binary-only persistent result exceeds 32KiB: %d", len(persistentJSON))
|
||||
}
|
||||
|
||||
taskDir := filepath.Join(service.localBinaryResultRoot(), "task-123")
|
||||
entries, err := os.ReadDir(taskDir)
|
||||
if err != nil {
|
||||
t.Fatalf("read local result dir: %v", err)
|
||||
}
|
||||
if len(entries) != 1 {
|
||||
t.Fatalf("same payload should reuse one local file, got %d", len(entries))
|
||||
}
|
||||
taskInfo, err := os.Stat(taskDir)
|
||||
if err != nil {
|
||||
t.Fatalf("stat task directory: %v", err)
|
||||
}
|
||||
if taskInfo.Mode().Perm() != 0o750 {
|
||||
t.Fatalf("task directory mode = %v, want 0750", taskInfo.Mode().Perm())
|
||||
}
|
||||
fileInfo, err := entries[0].Info()
|
||||
if err != nil {
|
||||
t.Fatalf("stat result file: %v", err)
|
||||
}
|
||||
if fileInfo.Mode().Perm() != 0o640 {
|
||||
t.Fatalf("result file mode = %v, want 0640", fileInfo.Mode().Perm())
|
||||
}
|
||||
|
||||
wire, err := service.HydrateTaskResult(context.Background(), "task-123", persistent)
|
||||
if err != nil {
|
||||
t.Fatalf("hydrate local binary result: %v", err)
|
||||
}
|
||||
wireItem := wire["data"].([]any)[0].(map[string]any)
|
||||
if wireItem["b64_json"] != encoded {
|
||||
t.Fatalf("raw Base64 mismatch: got %v", wireItem["b64_json"])
|
||||
}
|
||||
if wireItem["data_uri"] != "data:image/png;base64,"+encoded {
|
||||
t.Fatalf("data URI mismatch: got %v", wireItem["data_uri"])
|
||||
}
|
||||
if wireItem["buffer"] != encoded {
|
||||
t.Fatalf("Buffer should hydrate as Base64: got %v", wireItem["buffer"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHydrateLocalBinaryResultReturnsExpiredAndCorruptedErrors(t *testing.T) {
|
||||
service := newLocalBinaryTestService(t)
|
||||
service.cfg.LocalResultTTLHours = 1
|
||||
encoded := base64.StdEncoding.EncodeToString([]byte("expiring result"))
|
||||
persistent, _, err := service.materializeLocalBinaryResult(context.Background(), "task-expired", map[string]any{"b64_json": encoded})
|
||||
if err != nil {
|
||||
t.Fatalf("materialize fixture: %v", err)
|
||||
}
|
||||
entries, err := os.ReadDir(filepath.Join(service.localBinaryResultRoot(), "task-expired"))
|
||||
if err != nil || len(entries) != 1 {
|
||||
t.Fatalf("read fixture result: entries=%v err=%v", entries, err)
|
||||
}
|
||||
path := filepath.Join(service.localBinaryResultRoot(), "task-expired", entries[0].Name())
|
||||
old := time.Now().Add(-2 * time.Hour)
|
||||
if err := os.Chtimes(path, old, old); err != nil {
|
||||
t.Fatalf("age fixture: %v", err)
|
||||
}
|
||||
_, err = service.HydrateTaskResult(context.Background(), "task-expired", persistent)
|
||||
assertClientErrorCode(t, err, "binary_result_expired")
|
||||
|
||||
now := time.Now()
|
||||
if err := os.Chtimes(path, now, now); err != nil {
|
||||
t.Fatalf("refresh fixture: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("tampered"), 0o640); err != nil {
|
||||
t.Fatalf("tamper fixture: %v", err)
|
||||
}
|
||||
_, err = service.HydrateTaskResult(context.Background(), "task-expired", persistent)
|
||||
assertClientErrorCode(t, err, "binary_result_corrupted")
|
||||
}
|
||||
|
||||
func TestMaterializeLocalBinaryResultEnforcesLimitsAndKeepsText(t *testing.T) {
|
||||
service := newLocalBinaryTestService(t)
|
||||
service.cfg.LocalResultMaxBytes = 4
|
||||
_, _, err := service.materializeLocalBinaryResult(context.Background(), "task-large", map[string]any{
|
||||
"b64_json": base64.StdEncoding.EncodeToString([]byte("too large")),
|
||||
})
|
||||
assertClientErrorCode(t, err, "binary_result_too_large")
|
||||
|
||||
persistent, changed, err := service.materializeLocalBinaryResult(context.Background(), "task-text", map[string]any{
|
||||
"message": "YWJjZA==",
|
||||
})
|
||||
if err != nil || changed || persistent["message"] != "YWJjZA==" {
|
||||
t.Fatalf("short Base64-like text should remain unchanged: result=%+v changed=%v err=%v", persistent, changed, err)
|
||||
}
|
||||
|
||||
service = newLocalBinaryTestService(t)
|
||||
service.cfg.LocalResultMaxBytes = 1024
|
||||
service.cfg.LocalResultMaxTaskBytes = 8
|
||||
_, _, err = service.materializeLocalBinaryResult(context.Background(), "task-total-large", map[string]any{
|
||||
"first_base64": base64.StdEncoding.EncodeToString([]byte("12345")),
|
||||
"second_base64": base64.StdEncoding.EncodeToString([]byte("67890")),
|
||||
})
|
||||
assertClientErrorCode(t, err, "binary_result_too_large")
|
||||
if _, statErr := os.Stat(filepath.Join(service.localBinaryResultRoot(), "task-total-large")); !errors.Is(statErr, os.ErrNotExist) {
|
||||
t.Fatalf("preflight task limit must not create partial files: %v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompactExpiredTaskResultDoesNotWriteFiles(t *testing.T) {
|
||||
service := newLocalBinaryTestService(t)
|
||||
service.cfg.LocalResultMaxBytes = 1
|
||||
encoded := base64.StdEncoding.EncodeToString([]byte("already expired result"))
|
||||
|
||||
persistent, changed, err := service.CompactExpiredTaskResultForStorage(context.Background(), "task-old", map[string]any{
|
||||
"b64_json": encoded,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("compact expired result: %v", err)
|
||||
}
|
||||
if !changed || !localBinaryResultHasPlaceholders(persistent) {
|
||||
t.Fatalf("expired result was not compacted: %+v", persistent)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(service.localBinaryResultRoot(), "task-old")); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("expired compaction must not create a task directory: %v", err)
|
||||
}
|
||||
_, err = service.HydrateTaskResult(context.Background(), "task-old", persistent)
|
||||
assertClientErrorCode(t, err, "binary_result_expired")
|
||||
}
|
||||
|
||||
func TestLocalBinaryStorageUnavailableDoesNotRetryProvider(t *testing.T) {
|
||||
err := localBinaryStorageError(errors.New("write failed"))
|
||||
assertClientErrorCode(t, err, "local_result_storage_unavailable")
|
||||
if clients.IsRetryable(err) {
|
||||
t.Fatal("local result storage failure must not call the provider again")
|
||||
}
|
||||
if retryDecisionForCandidate(store.RuntimeModelCandidate{}, err).Retry {
|
||||
t.Fatal("local result storage failure must not retry the same provider client")
|
||||
}
|
||||
if failoverDecisionForCandidate(store.RunnerPolicy{}, store.RuntimeModelCandidate{}, err).Retry {
|
||||
t.Fatal("local result storage failure must not fail over to another provider")
|
||||
}
|
||||
|
||||
service := newLocalBinaryTestService(t)
|
||||
service.cfg.LocalResultMinFreeBytes = 1 << 62
|
||||
_, _, err = service.materializeLocalBinaryResult(context.Background(), "task-low-disk", map[string]any{
|
||||
"b64_json": base64.StdEncoding.EncodeToString([]byte("disk preflight")),
|
||||
})
|
||||
assertClientErrorCode(t, err, "local_result_storage_unavailable")
|
||||
if _, statErr := os.Stat(filepath.Join(service.localBinaryResultRoot(), "task-low-disk")); !errors.Is(statErr, os.ErrNotExist) {
|
||||
t.Fatalf("disk preflight must not create partial task files: %v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
func bytesToAny(payload []byte) []any {
|
||||
result := make([]any, len(payload))
|
||||
for index, value := range payload {
|
||||
result[index] = float64(value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func assertClientErrorCode(t *testing.T, err error, code string) {
|
||||
t.Helper()
|
||||
var clientErr *clients.ClientError
|
||||
if !errors.As(err, &clientErr) || clientErr.Code != code {
|
||||
t.Fatalf("error = %v, want client error code %s", err, code)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user