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:
+1
-1
Submodule modules/backend updated: 0f192a4813...46073d30a5
@@ -15,10 +15,12 @@ package multiinstance
|
|||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"crypto/sha256"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/cookiejar"
|
"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) {
|
t.Run("cross_event_sse", func(t *testing.T) {
|
||||||
lastSeq := e.lastSeq(t, uc.apiB, convID)
|
lastSeq := e.lastSeq(t, uc.apiB, convID)
|
||||||
events := e.subscribe(t, uc.streamB, convID, lastSeq)
|
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{}
|
log := &lockedBuffer{}
|
||||||
cmd.Stdout, cmd.Stderr = log, log
|
cmd.Stdout, cmd.Stderr = log, log
|
||||||
|
// 演练环境可能带有空的对象存储变量。子进程必须按「未配置」启动,
|
||||||
|
// 否则测不到带类型空 *S3 回落本地目录的路径。
|
||||||
|
cmd.Env = withoutBlobstoreS3(os.Environ())
|
||||||
// 独立 cwd:serve 会向 ./data 写 skills/SQLite 等,不能落在测试目录。
|
// 独立 cwd:serve 会向 ./data 写 skills/SQLite 等,不能落在测试目录。
|
||||||
cmd.Dir = t.TempDir()
|
cmd.Dir = t.TempDir()
|
||||||
if err := cmd.Start(); err != nil {
|
if err := cmd.Start(); err != nil {
|
||||||
@@ -324,6 +342,52 @@ func (e *env) startInstance(t *testing.T, name, addr string) *instance {
|
|||||||
return inst
|
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) {
|
func (e *env) waitHealthy(t *testing.T, inst *instance) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
deadline := time.Now().Add(60 * time.Second)
|
deadline := time.Now().Add(60 * time.Second)
|
||||||
|
|||||||
Reference in New Issue
Block a user