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 <cursoragent@cursor.com>
This commit is contained in:
2026-10-13 01:26:29 +00:00
co-authored by Cursor
parent 95c28015f5
commit ca0ab46d5b
2 changed files with 65 additions and 1 deletions
@@ -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)