Files
Agent-System-Backend/internal/store/lease_pg_test.go
T
weichen 603f1c0fa4
ci / vet-and-test (pull_request) Successful in 57s
Add execution ownership leases with epoch fencing and persistent run commands (U5)
Why: plan U5/KTD4+KTD5 - before a second instance may serve writes, conversation execution must have a single valid owner (DB lease, epoch increments on takeover), stale owners must not settle (in-transaction fence check), and cancel/HITL must reach the executing instance via a persistent command inbox.

Evidence: full suite green twice-relevant on SQLite and real PostgreSQL 18.6 (35 packages each tier) - lease tests (claim/contention/re-claim/renew fencing/expiry takeover/monotonic epoch, command inbox idempotent enqueue, atomic concurrent claim) and multi-instance tests (same-conversation contention rejected, stale-owner settle fenced with run unchanged, cross-instance cancel consumed exactly once by the owner instance, non-owner does not consume).

Impact: v23 migration (conversation_executions + run_commands); ClaimConversation stacks a DB lease on PG tiers (15min, release on conversation release); settle path fenced (fenced runs emit run.fenced, no terminal write); StartCommandConsumer polls the inbox for commands on locally-owned executions (cancel routes to local cascade or DB-level close-out; HITL revalidates gate and rejects expired explicitly); server cancel/HITL fall back to command enqueue with 202 when the run is not local; app wires instance identity (host:addr on PG tiers) and starts the consumer.

Refs: proposals/2026-10-11-collaboration-storage-and-distribution.md U5 KTD4/KTD5; R4/R10.
2026-10-11 12:25:31 +00:00

122 lines
3.8 KiB
Go

package store
import (
"errors"
"testing"
"time"
)
// 协作存储 U5(KTD4):会话执行租约与 epoch fencing。
// 双档(PG 由 CI 真库 job 覆盖咨询锁与跨实例语义;SQLite 验证条件逻辑)。
func TestExecutionClaimAndContention(t *testing.T) {
st := openTestStore(t)
// 首次占用:epoch=1。
e1, err := st.TryClaimExecution("cnv_1", "instA", "run_1", 60)
if err != nil || e1 != 1 {
t.Fatalf("claim A: epoch=%d err=%v", e1, err)
}
// 他实例争抢:租约未到期 → ErrExecutionBusy。
_, err = st.TryClaimExecution("cnv_1", "instB", "run_2", 60)
if !errors.Is(err, ErrExecutionBusy) {
t.Fatalf("B should be busy: %v", err)
}
// 同实例重占(新回合):允许,epoch 递增。
e2, err := st.TryClaimExecution("cnv_1", "instA", "run_3", 60)
if err != nil || e2 != 2 {
t.Fatalf("re-claim A: epoch=%d err=%v", e2, err)
}
// 续租 fencing:正确 (instance,epoch) 成功;旧 epoch 失败。
if !st.RenewExecution("cnv_1", "instA", e2, 60) {
t.Fatal("renew with current epoch should succeed")
}
if st.RenewExecution("cnv_1", "instA", e1, 60) {
t.Fatal("renew with stale epoch must fail")
}
// 主动释放后他实例可占,epoch 继续递增(单调 fencing)。
st.ReleaseExecution("cnv_1", "instA", e2)
e3, err := st.TryClaimExecution("cnv_1", "instB", "run_4", 60)
if err != nil || e3 != 3 {
t.Fatalf("takeover B: epoch=%d err=%v", e3, err)
}
// fence 校验:B 当前 OK;A 的旧代次失败。
if !st.ExecutionFenceOK("cnv_1", "instB", e3) {
t.Fatal("B fence should be ok")
}
if st.ExecutionFenceOK("cnv_1", "instA", e2) {
t.Fatal("A stale fence must fail")
}
}
func TestExecutionLeaseExpiryTakeover(t *testing.T) {
st := openTestStore(t)
// 1 秒租约到期后他实例接管。
if _, err := st.TryClaimExecution("cnv_2", "instA", "run_1", 1); err != nil {
t.Fatal(err)
}
time.Sleep(1100 * time.Millisecond)
e2, err := st.TryClaimExecution("cnv_2", "instB", "run_2", 60)
if err != nil || e2 != 2 {
t.Fatalf("expired takeover: epoch=%d err=%v", e2, err)
}
// 旧 owner 续租被拒(fencing)。
if st.RenewExecution("cnv_2", "instA", 1, 60) {
t.Fatal("stale renew must fail after takeover")
}
}
// 命令收件箱(KTD5):幂等投递、原子认领、并发认领不重复。
func TestRunCommandsClaim(t *testing.T) {
st := openTestStore(t)
if err := st.EnqueueCommand("cmd_1", "cancel", "run_9", "cnv_9", 0, map[string]string{"owner": "o"}); err != nil {
t.Fatal(err)
}
// 幂等:重复投递不新增。
if err := st.EnqueueCommand("cmd_1", "cancel", "run_9", "cnv_9", 0, nil); err != nil {
t.Fatal(err)
}
got, err := st.ClaimPendingCommands("instA", 8)
if err != nil || len(got) != 1 || got[0].CommandID != "cmd_1" || got[0].Kind != "cancel" {
t.Fatalf("claim: %v err=%v", got, err)
}
state, consumer, err := st.CommandState("cmd_1")
if err != nil || state != "consumed" || consumer != "instA" {
t.Fatalf("state=%s consumer=%s err=%v", state, consumer, err)
}
// 已消费不再返回。
got, err = st.ClaimPendingCommands("instB", 8)
if err != nil || len(got) != 0 {
t.Fatalf("consumed must not re-deliver: %v err=%v", got, err)
}
}
func TestRunCommandsConcurrentSingleClaim(t *testing.T) {
st := openTestStore(t)
for i := 0; i < 8; i++ {
if err := st.EnqueueCommand(newID("cmd"), "cancel", newID("run"), "cnv_c", 0, nil); err != nil {
t.Fatal(err)
}
}
var mu = make(chan int, 2)
total := 0
done := make(chan struct{})
for w := 0; w < 2; w++ {
go func(w int) {
got, err := st.ClaimPendingCommands("inst"+string(rune('A'+w)), 8)
if err == nil {
mu <- len(got)
}
done <- struct{}{}
}(w)
}
<-done
<-done
close(mu)
for n := range mu {
total += n
}
if total != 8 {
t.Fatalf("并发认领应恰好 8 条(不重复不丢失):total=%d", total)
}
}