test: cover dispatch loss and workflow commit fault boundaries

This commit is contained in:
2026-09-18 10:52:19 +08:00
parent 19cb70368e
commit 1f1d833f69
12 changed files with 697 additions and 38 deletions

View File

@@ -0,0 +1,341 @@
package mysql
import (
"bytes"
"context"
"io"
"net"
"os"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
core "git.yuntuo.hk/k2safe/orbit/model"
"git.yuntuo.hk/k2safe/orbit/weave/instance"
driver "github.com/go-sql-driver/mysql"
)
// commitFaultProxy 只存在于测试二进制。代理原始MySQL包,不保存SQL、凭据或业务正文。
// BEFORE在COMMIT发往MySQL前断线;AFTER收到真实MySQL成功包后、转交驱动前断线。
type commitFaultProxy struct {
mode atomic.Int32
reached chan string
mu sync.Mutex
closed bool
active map[net.Conn]struct{}
wg sync.WaitGroup
}
func readFaultPacket(c net.Conn) ([]byte, error) {
var header [4]byte
if _, err := io.ReadFull(c, header[:]); err != nil {
return nil, err
}
size := int(header[0]) | int(header[1])<<8 | int(header[2])<<16
packet := make([]byte, size+4)
copy(packet, header[:])
_, err := io.ReadFull(c, packet[4:])
return packet, err
}
func newCommitFaultProxy(t *testing.T, dsn string) (*commitFaultProxy, string) {
t.Helper()
cfg, err := driver.ParseDSN(dsn)
if err != nil {
t.Fatal("invalid disposable DSN")
}
host, _, err := net.SplitHostPort(cfg.Addr)
if err != nil || cfg.Net != "tcp" || host != "127.0.0.1" || cfg.TLSConfig != "" {
t.Fatal("packet faults require disposable plaintext loopback MySQL")
}
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
p := &commitFaultProxy{reached: make(chan string, 1), active: map[net.Conn]struct{}{}}
p.wg.Add(1)
t.Cleanup(func() {
listener.Close()
p.mu.Lock()
p.closed = true
for c := range p.active {
c.Close()
}
p.mu.Unlock()
p.wg.Wait()
})
target := cfg.Addr
go func() {
defer p.wg.Done()
for {
down, err := listener.Accept()
if err != nil {
return
}
p.mu.Lock()
if p.closed {
p.mu.Unlock()
down.Close()
return
}
p.active[down] = struct{}{}
p.mu.Unlock()
p.wg.Add(1)
go func() {
defer p.wg.Done()
defer down.Close()
defer func() { p.mu.Lock(); delete(p.active, down); p.mu.Unlock() }()
up, err := net.DialTimeout("tcp", target, 3*time.Second)
if err != nil {
return
}
defer up.Close()
var awaiting atomic.Bool
clientDone := make(chan struct{})
go func() {
defer close(clientDone)
defer up.Close()
for {
packet, err := readFaultPacket(down)
if err != nil {
return
}
if len(packet) > 5 && packet[3] == 0 && packet[4] == 3 && strings.EqualFold(strings.TrimSpace(string(packet[5:])), "COMMIT") {
switch p.mode.Swap(0) {
case 1:
p.reached <- "BEFORE"
return
case 2:
awaiting.Store(true)
}
}
if _, err = io.Copy(up, bytes.NewReader(packet)); err != nil {
return
}
}
}()
for {
packet, err := readFaultPacket(up)
if err != nil {
break
}
if awaiting.Swap(false) {
// COMMIT成功包:序号1、OK、0受影响行、0插入ID,IN_TRANS标志已清除。
if len(packet) >= 11 && packet[3] == 1 && packet[4] == 0 && packet[5] == 0 && packet[6] == 0 && packet[7]&1 == 0 {
p.reached <- "AFTER"
} else {
p.reached <- "UNEXPECTED_COMMIT_RESPONSE"
}
break
}
if _, err = io.Copy(down, bytes.NewReader(packet)); err != nil {
break
}
}
down.Close()
up.Close()
<-clientDone
}()
}
}()
cfg.Addr = listener.Addr().String()
return p, cfg.FormatDSN()
}
func (p *commitFaultProxy) expect(t *testing.T, after bool, err error) {
t.Helper()
if err == nil {
t.Fatal("COMMIT wire cut was reported as success")
}
want := "BEFORE"
if after {
want = "AFTER"
}
select {
case got := <-p.reached:
if got != want {
t.Fatalf("wrong COMMIT boundary: %s", got)
}
case <-time.After(time.Second):
t.Fatal("COMMIT fault not reached")
}
}
func TestIntegrationWorkflowCommitFaults(t *testing.T) {
dsn := os.Getenv("ORBIT_TEST_MYSQL_DSN")
if dsn == "" {
t.Skip("requires disposable MySQL")
}
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Second)
defer cancel()
db, err := Open(ctx, dsn, 24, 6)
if err != nil {
t.Fatal(err)
}
defer db.Close()
if err = Migrate(ctx, db); err != nil {
t.Fatal(err)
}
for _, after := range []bool{false, true} {
mode := "before"
if after {
mode = "after"
}
for _, boundary := range []string{"start", "successor", "poll_wait", "poll_next"} {
t.Run(boundary+"_"+mode+"_commit", func(t *testing.T) {
f := newClockFixture(t, ctx, db)
spec := runtimeSpec(f.resources.QueueID, 2)
if strings.HasPrefix(boundary, "poll_") {
spec = pollingSpec(f.resources.QueueID)
}
grantWorkflowInstance(t, f)
input := publishInstanceSpec(t, f, spec)
key := string(executionID(t))
api := instance.New(NewWorkflowInstanceStore(db))
var original instance.StartResult
var c workflowCandidate
var firstTask core.ID
var originalNext, pollDeadline, expectedNext time.Time
if boundary != "start" {
original, err = api.Start(ctx, f.token, key, input)
if err != nil {
t.Fatal(err)
}
ref := retryStart(t, f)
firstTask = ref.TaskID
if boundary == "successor" {
retryFinish(t, f, ref, "SUCCEEDED", false)
} else {
finishQueryOnly(t, f, ref, "PENDING")
}
c = runtimeInbox(t, f, ref.TaskID)
if strings.HasPrefix(boundary, "poll_") {
if err := db.QueryRowContext(ctx, `SELECT deadline_at FROM weave_poll_states WHERE namespace_id=? AND workflow_id=?`, f.resources.NamespaceID, original.Instance.ID).Scan(&pollDeadline); err != nil {
t.Fatal(err)
}
var occurred time.Time
if err := db.QueryRowContext(ctx, `SELECT e.occurred_at FROM weave_inbox i JOIN orbit_events e ON e.namespace_id=i.namespace_id AND e.id=i.event_id WHERE i.namespace_id=? AND i.id=?`, f.resources.NamespaceID, c.id).Scan(&occurred); err != nil {
t.Fatal(err)
}
expectedNext = minimumTime(occurred.Add(time.Second), pollDeadline)
}
if boundary == "poll_next" {
runtimeConsume(t, f, newRuntime(t, db), ref.TaskID)
c = clockCandidate(t, f, original.Instance.ID, "query")
if err := db.QueryRowContext(ctx, `SELECT next_poll_at FROM weave_poll_states WHERE namespace_id=? AND workflow_id=?`, f.resources.NamespaceID, original.Instance.ID).Scan(&originalNext); err != nil {
t.Fatal(err)
}
waitClockDue(t, f, c)
}
}
proxy, proxiedDSN := newCommitFaultProxy(t, dsn)
faultDB, err := Open(ctx, proxiedDSN, 2, 1)
if err != nil {
t.Fatal(err)
}
defer faultDB.Close()
if after {
proxy.mode.Store(2)
} else {
proxy.mode.Store(1)
}
if boundary == "start" {
_, err = instance.New(NewWorkflowInstanceStore(faultDB)).Start(ctx, f.token, key, input)
} else {
_, err = newRuntime(t, faultDB).process(ctx, c, false)
}
proxy.expect(t, after, err)
// 回读通过直接连接,与故障连接、其内存回执和连接池完全独立。
if boundary == "start" {
want := 0
if after {
want = 1
}
if f.count("weave_instances", "1=1") != want || f.count("pulse_tasks", "1=1") != want {
t.Fatal("partial start transaction")
}
original, err = api.Start(ctx, f.token, key, input)
if err != nil || original.Deduplicated != after {
t.Fatal("original start identity did not reconcile unknown COMMIT", err)
}
ref := retryStart(t, f)
retryFinish(t, f, ref, "SUCCEEDED", false)
runtimeConsume(t, f, newRuntime(t, db), ref.TaskID)
} else {
wantTasks := 1
if after && (boundary == "successor" || boundary == "poll_next") {
wantTasks = 2
}
if f.count("pulse_tasks", "1=1") != wantTasks {
t.Fatal("partial or duplicated successor transaction")
}
if boundary != "poll_next" {
want := 0
if after {
want = 1
}
if f.count("weave_inbox", "source_task_id=? AND state='PROCESSED'", firstTask) != want {
t.Fatal("inbox ACK disagrees with COMMIT")
}
}
if boundary == "poll_wait" {
want := 0
if after {
want = 1
}
if f.count("weave_poll_states", "current_task_id IS NULL AND next_poll_at IS NOT NULL AND used_rounds=1") != want {
t.Fatal("PENDING result and wait were not atomic")
}
}
if boundary == "poll_next" {
want := 1
if after {
want = 2
}
if f.count("weave_poll_rounds", "1=1") != want || f.count("weave_poll_states", "used_rounds=?", want) != 1 {
t.Fatal("new round and budget were not atomic")
}
if !after && f.count("weave_poll_states", "next_poll_at=?", originalNext) != 1 {
t.Fatal("rollback changed original next wake")
}
}
if _, err = newRuntime(t, db).process(ctx, c, false); err != nil {
t.Fatal("fresh runtime could not reconcile original candidate", err)
}
if boundary == "poll_wait" {
if f.count("weave_poll_states", "next_poll_at=?", expectedNext) != 1 {
t.Fatal("reconciled wait was timed from retry rather than original result")
}
clock := clockCandidate(t, f, original.Instance.ID, "query")
waitClockDue(t, f, clock)
runClock(t, f, clock)
}
}
last := retryStart(t, f)
if strings.HasPrefix(boundary, "poll_") {
finishPoll(t, f, last, "DONE")
} else {
retryFinish(t, f, last, "SUCCEEDED", false)
runtimeConsume(t, f, newRuntime(t, db), last.TaskID)
}
v := runtimeView(t, f, original.Instance.ID, "SUCCEEDED")
if v.DefinitionVersionID != original.Instance.DefinitionVersionID || !v.DeadlineAt.Equal(original.Instance.DeadlineAt) || f.count("pulse_tasks", "1=1") != 2 || f.count("pulse_attempts", "1=1") != 2 || f.used() != 0 || f.count("weave_inbox", "state='PENDING'") != 0 {
t.Fatal("COMMIT recovery duplicated work or reset state")
}
if strings.HasPrefix(boundary, "poll_") && f.count("weave_poll_states", "used_rounds=2 AND current_task_id IS NULL AND resolved_at IS NOT NULL") != 1 {
t.Fatal("COMMIT recovery lost final query budget")
}
if strings.HasPrefix(boundary, "poll_") && f.count("weave_poll_states", "deadline_at=? AND max_rounds=3 AND max_attempts_per_round=1", pollDeadline) != 1 {
t.Fatal("COMMIT recovery reset original poll deadline or attempt budget")
}
replay, err := api.Start(ctx, f.token, key, input)
if err != nil || !replay.Deduplicated || replay.Instance.ID != original.Instance.ID || !replay.Instance.DeadlineAt.Equal(original.Instance.DeadlineAt) {
t.Fatal("start receipt changed after completion", err)
}
t.Logf("%s: real TCP COMMIT %s cut; same identity/candidate resumed; exactly two tasks/attempts and original deadline", boundary, mode)
})
}
}
}