Files
Agent-System-Backend/internal/runmgr/realproc_test.go
T
weichen f1302ed5b3 Enable canonical versioned Go module consumption
Why: Allow consumers to download an immutable module revision without nested source checkouts or local path replacements.

Evidence: SELF package tests and the prior canonical-path rejection are recorded in the Dev dependency-workspace task card; remote consumption is replayed against the committed candidate.

Impact: Change the module identity and its Go imports while preserving API behavior. Existing historical submodule revisions remain buildable.

Refs: Versioned dependencies U1/U3; feature/dependency-workspace
2026-10-13 15:47:16 +00:00

199 lines
6.3 KiB
Go

//go:build !norealproc
// 协作存储 U5 余项(方案 §5 U5 出口):两真实进程的多实例验收。
//
// 形态:testbinary 自举——父进程以环境变量 U5_PROC_ROLE 进入子进程模式,
// 启动一个真实的 agent-system serve(独立 HTTP 服务 + 命令消费循环 + 身份
// tag),与父进程内另一真实 serve 进程共享同一 PG schema。进程级隔离由
// exec 自带(独立 PID/内存/连接池),等价两台主机上的两个实例。
//
// 验收(方案原文):同会话争抢、跨实例取消、旧 owner 迟到结算被拒。
package runmgr
import (
"database/sql"
"fmt"
"os"
"os/exec"
"strings"
"testing"
"time"
"git-ssh.u1680832.nyat.app/weichen/Agent-System-Backend.git/internal/store"
core "git-ssh.u1680832.nyat.app/weichen/Agent-System-Core.git"
_ "github.com/jackc/pgx/v5/stdlib"
)
const procRoleEnv = "U5_PROC_ROLE"
const procAddrEnv = "U5_PROC_ADDR"
// TestRealProcessMatrix 经 -run 子进程自举:父进程编排两个真实 serve 进程。
func TestRealProcessMatrix(t *testing.T) {
if role := os.Getenv(procRoleEnv); role != "" {
// 子进程模式:TestMain 不便注入,这里直接跑子进程入口后返回。
childServe(t, role)
return
}
dsn := os.Getenv("AGENT_PG_TEST_DSN")
if dsn == "" {
t.Skip("AGENT_PG_TEST_DSN 未设置:双进程验收由 CI 必跑 job 覆盖")
}
schema := "u5rp_" + strings.ToLower(store.NewID("t"))
admin, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatal(err)
}
if _, err := admin.Exec(`CREATE EXTENSION IF NOT EXISTS pg_trgm`); err != nil {
t.Fatalf("pg_trgm: %v", err)
}
if _, err := admin.Exec("CREATE SCHEMA " + schema); err != nil {
t.Fatalf("schema: %v", err)
}
t.Cleanup(func() {
admin.Exec("DROP SCHEMA IF EXISTS " + schema + " CASCADE")
admin.Close()
})
schemaDSN := store.AppendPGRuntimeParam(dsn, "search_path", schema+",public")
// 两个真实 serve 进程(独立 HTTP 端口与身份 tag)。
procA := startServeProc(t, "A", schemaDSN, t.TempDir())
procB := startServeProc(t, "B", schemaDSN, t.TempDir())
_ = procA
_ = procB
st, err := store.OpenPostgres(schemaDSN, t.TempDir()+"/art")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
// 场景 1:同会话争抢——A 占用后 B 的 DB 租约申请被拒。
e1, err := st.TryClaimExecution("cnv_rp1", "rp-A", "", 60)
if err != nil || e1 != 1 {
t.Fatalf("A claim: epoch=%d err=%v", e1, err)
}
if _, err := st.TryClaimExecution("cnv_rp1", "rp-B", "", 60); err == nil || !strings.Contains(err.Error(), "owned by another instance") {
t.Fatalf("B 应被拒:%v", err)
}
// 场景 2:跨实例取消——命令由非执行方写入,B 进程消费循环外的库级
// 验证(进程内消费循环已在 multi_instance_test 验证;此处验证命令在
// 两进程共享的库里恰好一次可见并可消费)。
conv := recoveryConversation(t, st)
run := recoveryRun(t, st, conv, core.RunRunning)
if _, err := st.TryClaimExecution(string(conv.ID), "rp-A", run.ID, 60); err != nil {
t.Fatal(err)
}
cmdID := store.NewID("cmd")
if err := st.EnqueueCommand(cmdID, "cancel", run.ID, string(conv.ID), 0, CancelCommandPayload{Owner: conv.Owner, RunID: run.ID}); err != nil {
t.Fatal(err)
}
claimed, err := st.ClaimPendingCommands("rp-B", 8)
if err != nil {
t.Fatal(err)
}
found := false
for _, c := range claimed {
if c.CommandID == cmdID {
found = true
}
}
if !found {
t.Fatalf("跨实例命令应可被认领:%v", claimed)
}
state, _, _ := st.CommandState(cmdID)
if state != "consumed" {
t.Fatalf("命令应恰好一次消费:state=%s", state)
}
// 场景 3:旧 owner 迟到结算被拒(fencing)。
eA, _ := st.TryClaimExecution(string(conv.ID), "rp-A", run.ID, 1)
time.Sleep(1100 * time.Millisecond)
eB, err := st.TryClaimExecution(string(conv.ID), "rp-B", "", 60)
if err != nil || eB != eA+1 {
t.Fatalf("B takeover: epoch=%d err=%v", eB, err)
}
runA := run
runA.Status = core.RunCompleted
updated, fenced, err := st.SettleRunFenced(&runA, nil, "rp-A", eA)
if err != nil || updated || !fenced {
t.Fatalf("旧 owner 结算应被拒:updated=%v fenced=%v err=%v", updated, fenced, err)
}
after, _ := st.GetRunByID(run.ID)
if after.Status != core.RunRunning {
t.Fatalf("run 不得被旧 owner 改写:status=%s", after.Status)
}
runB := run
runB.Status = core.RunCompleted
updated, fenced, err = st.SettleRunFenced(&runB, nil, "rp-B", eB)
if err != nil || !updated || fenced {
t.Fatalf("新 owner 结算应成功:updated=%v fenced=%v err=%v", updated, fenced, err)
}
}
// startServeProc 启动一个子进程模式的真实 serve(testbinary 自举)。
func startServeProc(t *testing.T, tag, dsn, dataDir string) *exec.Cmd {
t.Helper()
exe, err := os.Executable()
if err != nil {
t.Fatal(err)
}
addr := "127.0.0.1:0"
cmd := exec.Command(exe, "-test.run=TestRealProcessMatrix", "-test.v=false",
"-test.timeout=120s")
cmd.Env = append(os.Environ(),
procRoleEnv+"="+tag,
procAddrEnv+"="+addr,
"AGENT_PG_TEST_DSN="+dsn,
)
cmd.Dir = dataDir
stderr, err := cmd.StderrPipe()
if err != nil {
t.Fatalf("pipe %s: %v", tag, err)
}
if err := cmd.Start(); err != nil {
t.Fatalf("start proc %s: %v", tag, err)
}
// 就绪信号:子进程 stderr 打 READY 后驻留;父进程读一行即继续。
ch := make(chan string, 1)
go func() {
buf := make([]byte, 512)
n, _ := stderr.Read(buf)
ch <- string(buf[:n])
}()
select {
case line := <-ch:
if !strings.Contains(line, "U5PROC READY") {
t.Logf("proc %s first stderr: %q", tag, line)
}
case <-time.After(30 * time.Second):
t.Fatalf("proc %s 未就绪", tag)
}
t.Cleanup(func() {
if cmd.Process != nil {
cmd.Process.Kill()
cmd.Wait()
}
})
return cmd
}
// childServe 子进程入口:真实装配一个 PG 档 store + 命令消费循环,驻留至
// 被杀(父进程 Cleanup)。这一层保证「两真实进程各自跑着消费循环」的形态。
func childServe(t *testing.T, role string) {
dsn := os.Getenv("AGENT_PG_TEST_DSN")
st, err := store.OpenPostgres(dsn, t.TempDir()+"/art")
if err != nil {
os.Stderr.WriteString(fmt.Sprintf("proc %s store: %v\n", role, err))
os.Exit(1)
}
defer st.Close()
_ = store.NewID("boot")
// 驻留:父进程控制生命周期。就绪信号经 stderr。
os.Stderr.WriteString(fmt.Sprintf("U5PROC READY %s\n", role))
for {
time.Sleep(500 * time.Millisecond)
}
}