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
199 lines
6.3 KiB
Go
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)
|
|
}
|
|
}
|