From ca0ab46d5b1cd150a97647e46f60fdaad8709dd4 Mon Sep 17 00:00:00 2001 From: weichen Date: Tue, 13 Oct 2026 01:26:29 +0000 Subject: [PATCH] Pin the blobstore fallback and cover cross-instance uploads Why: an unset object store must keep artifact bytes available to the other process, which the store-level test does not execute. Evidence: cross_artifact_roundtrip failed with EOF on backend c6fb130 and passed on 46073d30 (SELF, isolated u7drill-k4). Impact: Dev now builds the fixed backend. Second-instance write traffic stays closed. Refs: modules/backend tests/integration/multi-instance/multi_instance_test.go Co-authored-by: Cursor --- modules/backend | 2 +- .../multi-instance/multi_instance_test.go | 64 +++++++++++++++++++ 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/modules/backend b/modules/backend index 0f192a48..46073d30 160000 --- a/modules/backend +++ b/modules/backend @@ -1 +1 @@ -Subproject commit 0f192a481388184df39f81c28b0587a7e16ec3ca +Subproject commit 46073d30a575e115aeb9112fd8674160dc2134d5 diff --git a/tests/integration/multi-instance/multi_instance_test.go b/tests/integration/multi-instance/multi_instance_test.go index 6cba49bf..6b4d9a0d 100644 --- a/tests/integration/multi-instance/multi_instance_test.go +++ b/tests/integration/multi-instance/multi_instance_test.go @@ -15,10 +15,12 @@ package multiinstance import ( "bufio" "bytes" + "crypto/sha256" "database/sql" "encoding/json" "fmt" "io" + "mime/multipart" "net" "net/http" "net/http/cookiejar" @@ -123,6 +125,19 @@ func TestTwoInstancesOverSharedPG(t *testing.T) { } }) + t.Run("cross_artifact_roundtrip", func(t *testing.T) { + // 未配置对象存储时,A 上传的正文必须落在共享本地目录,B 原样取回。 + payload := []byte("k4-cross-instance-artifact") + id := e.uploadArtifact(t, uc.api, projID, "k4.txt", payload) + code, raw := e.raw(t, uc.apiB, "GET", "/api/artifacts/"+id+"/content", nil) + if code != http.StatusOK { + t.Fatalf("B artifact content: status %d, body %.300s", code, raw) + } + if sha256.Sum256([]byte(raw)) != sha256.Sum256(payload) { + t.Fatalf("B downloaded different bytes") + } + }) + t.Run("cross_event_sse", func(t *testing.T) { lastSeq := e.lastSeq(t, uc.apiB, convID) events := e.subscribe(t, uc.streamB, convID, lastSeq) @@ -313,6 +328,9 @@ func (e *env) startInstance(t *testing.T, name, addr string) *instance { ) log := &lockedBuffer{} cmd.Stdout, cmd.Stderr = log, log + // 演练环境可能带有空的对象存储变量。子进程必须按「未配置」启动, + // 否则测不到带类型空 *S3 回落本地目录的路径。 + cmd.Env = withoutBlobstoreS3(os.Environ()) // 独立 cwd:serve 会向 ./data 写 skills/SQLite 等,不能落在测试目录。 cmd.Dir = t.TempDir() if err := cmd.Start(); err != nil { @@ -324,6 +342,52 @@ func (e *env) startInstance(t *testing.T, name, addr string) *instance { return inst } +func withoutBlobstoreS3(env []string) []string { + out := make([]string, 0, len(env)) + for _, item := range env { + if strings.HasPrefix(item, "BLOBSTORE_S3_") { + continue + } + out = append(out, item) + } + return out +} + +func (e *env) uploadArtifact(t *testing.T, c *http.Client, projectID, name string, payload []byte) string { + t.Helper() + var buf bytes.Buffer + writer := multipart.NewWriter(&buf) + part, err := writer.CreateFormFile("file", name) + if err != nil { + t.Fatal(err) + } + if _, err := part.Write(payload); err != nil { + t.Fatal(err) + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + req, err := http.NewRequest(http.MethodPost, e.baseFor(c)+"/api/projects/"+projectID+"/uploads", &buf) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", writer.FormDataContentType()) + resp, err := c.Do(req) + if err != nil { + t.Fatalf("upload: %v", err) + } + defer resp.Body.Close() + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + var info struct { + ArtifactID string `json:"artifactId"` + } + _ = json.Unmarshal(raw, &info) + if resp.StatusCode != http.StatusOK || info.ArtifactID == "" { + t.Fatalf("upload status %d id %q body %.300s", resp.StatusCode, info.ArtifactID, raw) + } + return info.ArtifactID +} + func (e *env) waitHealthy(t *testing.T, inst *instance) { t.Helper() deadline := time.Now().Add(60 * time.Second)