185 lines
4.7 KiB
Go
185 lines
4.7 KiB
Go
package inbox
|
||
|
||
import (
|
||
"context"
|
||
"encoding/binary"
|
||
"errors"
|
||
"io"
|
||
"net"
|
||
"strings"
|
||
"sync"
|
||
"sync/atomic"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/go-sql-driver/mysql"
|
||
)
|
||
|
||
// commitLossGateway 只用于无TLS的隔离MySQL回归;捕获COMMIT的真实成功包后丢弃并断连。
|
||
// 不伪造SQL结果,也不修改数据库中的提交状态。
|
||
type commitLossGateway struct {
|
||
listener net.Listener
|
||
dropped chan struct{}
|
||
armed atomic.Bool
|
||
mu sync.Mutex
|
||
closing bool
|
||
connections map[net.Conn]struct{}
|
||
wg sync.WaitGroup
|
||
accepted chan struct{}
|
||
}
|
||
|
||
func newCommitLossGateway(t *testing.T, target string) *commitLossGateway {
|
||
t.Helper()
|
||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
g := &commitLossGateway{listener: listener, dropped: make(chan struct{}, 1), connections: map[net.Conn]struct{}{}, accepted: make(chan struct{})}
|
||
go func() {
|
||
defer close(g.accepted)
|
||
for {
|
||
down, err := listener.Accept()
|
||
if err != nil {
|
||
return
|
||
}
|
||
g.wg.Add(1)
|
||
go func() {
|
||
defer g.wg.Done()
|
||
defer down.Close()
|
||
up, err := net.DialTimeout("tcp", target, 3*time.Second)
|
||
if err != nil {
|
||
return
|
||
}
|
||
defer up.Close()
|
||
g.mu.Lock()
|
||
if g.closing {
|
||
g.mu.Unlock()
|
||
return
|
||
}
|
||
g.connections[down] = struct{}{}
|
||
g.connections[up] = struct{}{}
|
||
g.mu.Unlock()
|
||
defer func() { g.mu.Lock(); delete(g.connections, down); delete(g.connections, up); g.mu.Unlock() }()
|
||
var committing atomic.Bool
|
||
done := make(chan struct{}, 2)
|
||
go func() {
|
||
defer func() { done <- struct{}{} }()
|
||
for {
|
||
packet, err := readMySQLPacket(down)
|
||
if err != nil {
|
||
return
|
||
}
|
||
if packet[3] == 0 && len(packet) > 4 && packet[4] == 3 && strings.EqualFold(strings.TrimSpace(string(packet[5:])), "COMMIT") {
|
||
committing.Store(true)
|
||
}
|
||
if writeMySQLPacket(up, packet) != nil {
|
||
return
|
||
}
|
||
}
|
||
}()
|
||
go func() {
|
||
defer func() { done <- struct{}{} }()
|
||
for {
|
||
packet, err := readMySQLPacket(up)
|
||
if err != nil {
|
||
return
|
||
}
|
||
if committing.Load() && packet[3] == 1 && len(packet) > 4 && packet[4] == 0 && g.armed.CompareAndSwap(true, false) {
|
||
g.dropped <- struct{}{}
|
||
return
|
||
}
|
||
if writeMySQLPacket(down, packet) != nil {
|
||
return
|
||
}
|
||
}
|
||
}()
|
||
<-done
|
||
down.Close()
|
||
up.Close()
|
||
<-done
|
||
}()
|
||
}
|
||
}()
|
||
t.Cleanup(func() {
|
||
listener.Close()
|
||
<-g.accepted
|
||
g.mu.Lock()
|
||
g.closing = true
|
||
for c := range g.connections {
|
||
c.Close()
|
||
}
|
||
g.mu.Unlock()
|
||
g.wg.Wait()
|
||
})
|
||
return g
|
||
}
|
||
func readMySQLPacket(r io.Reader) ([]byte, error) {
|
||
var header [4]byte
|
||
if _, err := io.ReadFull(r, header[:]); err != nil {
|
||
return nil, err
|
||
}
|
||
size := int(binary.LittleEndian.Uint32(header[:]) & 0x00ffffff)
|
||
packet := make([]byte, 4+size)
|
||
copy(packet, header[:])
|
||
_, err := io.ReadFull(r, packet[4:])
|
||
return packet, err
|
||
}
|
||
func writeMySQLPacket(w io.Writer, b []byte) error {
|
||
for len(b) > 0 {
|
||
n, err := w.Write(b)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if n == 0 {
|
||
return io.ErrShortWrite
|
||
}
|
||
b = b[n:]
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func TestIntegrationReceiverCommitResponseLoss(t *testing.T) {
|
||
direct, dsn := integrationDB(t)
|
||
cfg, err := mysql.ParseDSN(dsn)
|
||
if err != nil || cfg.TLSConfig != "" {
|
||
t.Fatal("commit fault requires isolated plaintext MySQL")
|
||
}
|
||
gateway := newCommitLossGateway(t, cfg.Addr)
|
||
cfg.Addr = gateway.listener.Addr().String()
|
||
ctx, stop := context.WithTimeout(context.Background(), 15*time.Second)
|
||
defer stop()
|
||
proxied, err := Open(ctx, cfg.FormatDSN())
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer proxied.Close()
|
||
s := testStore(t, proxied)
|
||
event := testEvent(t, s.queue)
|
||
gateway.armed.Store(true)
|
||
if _, err = s.Apply(ctx, event); !errors.Is(err, ErrCommitUnconfirmed) {
|
||
t.Fatal("lost COMMIT response acknowledged", err)
|
||
}
|
||
select {
|
||
case <-gateway.dropped:
|
||
case <-ctx.Done():
|
||
t.Fatal("real COMMIT success was not intercepted")
|
||
}
|
||
restarted, err := New(direct, s.consumer, s.queue)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
view, err := restarted.View(ctx, event.AggregateID)
|
||
if err != nil || view.AppliedEvents != 1 || inboxCount(t, ctx, restarted) != 1 {
|
||
t.Fatal("expected actual commit before response loss", err)
|
||
}
|
||
result, err := restarted.Apply(ctx, event)
|
||
if err != nil || !result.Duplicate || !result.Applied {
|
||
t.Fatal("unknown commit not reconciled from durable identity", err)
|
||
}
|
||
after, err := restarted.View(ctx, event.AggregateID)
|
||
if err != nil || after.AppliedEvents != 1 {
|
||
t.Fatal("unknown commit repeated business effect", err)
|
||
}
|
||
t.Log("proxy observed actual MySQL COMMIT OK and discarded it; caller returned unconfirmed, fresh store deduplicated the already committed event")
|
||
}
|