Fall back to local artifacts when object storage is unset #497
File diff suppressed because one or more lines are too long
@@ -39,14 +39,14 @@
|
||||
| C2 | 双进程**负载**下互相不拖累(热点会话/命令时延) | 上表 B2/B3 均 `--instances 2`(VU 粘滞 + 跨实例读 + 热点 409 入 expected_contention;非热点读阈值全过) | ✅ SELF(2026-10-12);非 GATE/REPLAY |
|
||||
| C3 | 双主机(跨机网络)验证:同套验收在两台主机执行 | A6 的 AGENT_BIN/DSN 指向远端;需用户指定第二主机 | ❌ 待资源 |
|
||||
| C4 | 故障演练:PG 断连/Redis 断连恢复(RTO≤15min) | `tests/ops/run_fault_drill.sh --fault all`;results `fault-20261012-142734`。pg-stop 与 pg-kill 的 F1–F5 均过,RTO 不到 1 秒且未重启应用。Redis:共享限额 20,断连期间按实例合计约 40,拉起后回到 20;无 5xx。G4 降级不可见、G5 启动时 Redis 不通则拒绝,记为发现 | ✅ SELF(2026-10-12);非 GATE/REPLAY |
|
||||
| C5 | 备份恢复演练(RPO≤5min/RTO≤60min,pg_dump+物理备份) | `tests/ops/run_backup_drill.sh`;results `backup-20261012-153441`。K1/K2/K3/K5/K6/K7 过:RPO 64s,RTO 4s,`pg_verifybackup` 与 `pg_restore --list` 通过,逻辑恢复计数等于 dump 时快照,`failed_count=0`。K4 FAIL:未配置 `BLOBSTORE_S3_ENDPOINT` 时上传在空的 `*S3` 上 panic,工件 sha256 与跨用户拒绝未测成。静态加密、凭据演练记 N/A;归档与数据同盘 | ⏳ SELF 部分通过(2026-10-12);K4 FAIL;非 GATE/REPLAY |
|
||||
| C5 | 备份恢复演练(RPO≤5min/RTO≤60min,pg_dump+物理备份) | `tests/ops/run_backup_drill.sh`。2026-10-12 `backup-20261012-153441` 的库恢复过、K4 因空 `*S3` panic 失败。2026-10-13 在 Backend `46073d30`(合入 `dc736e3`,同树)、Dev `a83588dd` 上重跑 `backup-20261013-013354`:K1–K7 过,RPO 65s,RTO 3s;K4 为 5 个工件 sha256 一致且跨用户项目/工件访问均为 404。静态加密、凭据演练记 N/A;归档与数据同盘 | ✅ SELF(2026-10-13);非 GATE/REPLAY |
|
||||
|
||||
## 放行结论(当前)
|
||||
|
||||
- **单实例团队档**:A1–A7 全绿 + B1–B5/B7 本机 SELF 容量窗口全过(见容量报告)。
|
||||
功能验收成立;**容量数字仅为 SELF**,换机/真实模型须重测。生产承诺仍待独立 REPLAY 或 GATE。
|
||||
- **双进程档**:正确性已证(A5/A6/C1),本机双实例负载 SELF 已过(C2)。C4 断连演练 SELF 已过。
|
||||
**仍不放行第二实例承接写请求**——缺独立 REPLAY/GATE,C3 未做,且 C5 的工件恢复(K4)失败。
|
||||
**仍不放行第二实例承接写请求**——缺独立 REPLAY/GATE,C3 未做。C5 的工件恢复(K4)已有 2026-10-13 本机 SELF,不是 GATE。
|
||||
- **双主机档**:未开始(C3),不放行。
|
||||
|
||||
## 已知边界与诚实声明
|
||||
@@ -58,4 +58,4 @@
|
||||
- 容量详情与 SHA 绑定见 Documents
|
||||
`docs/records/2026-10-12-u7-capacity-window-report.md`;原始
|
||||
`results/<ts>/` 在测量 worktree(gitignore),不入库。
|
||||
- B6 pgbench 与 C4 断连演练已有本机 SELF。C5 的库恢复 SELF 已过,工件上传 K4 失败。C3 双主机仍待第二台机器。证据等级仍缺独立 REPLAY 或 CI GATE。
|
||||
- B6 pgbench、C4 断连演练与 C5 备份恢复(含 K4 工件)已有本机 SELF。C3 双主机仍待第二台机器。证据等级仍缺独立 REPLAY 或 CI GATE。
|
||||
|
||||
+1
-1
@@ -725,7 +725,7 @@ pg_ctl -D /secure/restore-pgdata -l /secure/postgres.log -t 600 -w \
|
||||
|
||||
`-k` 必须是短路径。结果目录下的 socket 路径超过 107 字节时,postmaster 会拒绝启动(同日 `backup-20261012-150713` 的 `restore/postgres.log`)。等到 `SELECT pg_is_in_recovery()` 返回 `f` 再让应用连这个新端口。逻辑路径是在恢复实例上建空库,再 `pg_restore --no-owner`;表计数与 dump 当时的快照一致。
|
||||
|
||||
工件正文的库内 `content_path` 是绝对路径(`blobstore.Local.Put` 返回最终文件路径)。快照要还原到原来的目录,换目录后按库里的路径取文件会失败。这次演练没有把这一点跑通:未设置 `BLOBSTORE_S3_ENDPOINT` 时,`app.go` 把空的 `*S3` 传进 `OpenPostgresWithOptions`,接口值不是 nil,`finishOpen` 不再回落本地目录,上传在 `blobstore.(*S3).Put` 空指针 panic。K4(上传文件经 API 下载后 sha256 一致、跨用户拒绝)记 FAIL,产品代码未改。
|
||||
工件正文的库内 `content_path` 是绝对路径(`blobstore.Local.Put` 返回最终文件路径)。快照要还原到原来的目录,换目录后按库里的路径取文件会失败。2026-10-12 那次没有跑通上传:未设置 `BLOBSTORE_S3_ENDPOINT` 时,空的 `*S3` 放进对象存储接口后不再等于 nil,上传在 `blobstore.(*S3).Put` 空指针 panic,K4 记 FAIL。2026-10-13 已在 store 边界把这种带类型空指针当作未配置并回落本地目录(Backend `46073d30`;合入 main 的 `dc736e3` 与该提交同树)。同日在 Dev `a83588dd` 上重跑 `tests/ops/run_backup_drill.sh`,结果目录 `tests/ops/results/backup-20261013-013354`(SELF,原始结果另存于仓外 `u7-k4-retest-preserved-20261013`)。K1–K7 都过:RPO 65 秒,RTO 3 秒;K4 为 5 个工件 `checked=5`、`missing=0`、`mismatch=0`,跨用户访问项目和工件均为 404。绝对路径这一条仍然有效,快照继续还原到原目录。
|
||||
|
||||
### 8.7 PG / Redis 断连处置
|
||||
|
||||
|
||||
+1
-1
Submodule modules/backend updated: 0f192a4813...dc736e339d
+1
-1
Submodule modules/documents updated: cf74a200e8...05c13a42b1
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user