feat: add durable business inbox example and consumer crash recovery
This commit is contained in:
21
examples/result-inbox/README.md
Normal file
21
examples/result-inbox/README.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# 独立 MySQL Inbox 示例
|
||||
|
||||
这是接收方应用示例,使用正式 Orbit Go SDK 和自己的 MySQL 数据库。核心 SDK 不依赖数据库驱动;示例是独立 Go module,通过本地 replace 引用 SDK。
|
||||
|
||||
完整前置、调用、输出、故障处理与事务边界见 [运行手册](../../docs/examples/result-inbox.md)。
|
||||
|
||||
从本目录执行:
|
||||
|
||||
```sh
|
||||
GOWORK=off go run ./cmd/receiver init
|
||||
GOWORK=off go run ./cmd/receiver demo
|
||||
GOWORK=off go run ./cmd/receiver consume
|
||||
```
|
||||
|
||||
- `init`:使用 ORBIT_RECEIVER_DSN 初始化专用空数据库,不自动升级已有表。
|
||||
- `demo`:使用事件凭据和隔离队列产生结果,验证两次业务处理只推进一次,再确认并暂停本次订阅。
|
||||
- `consume`:消费 ORBIT_SUBSCRIPTION_ID 指定的已有订阅,RECEIVER_NAME 在重启和多实例之间保持固定。
|
||||
|
||||
[业务事务](internal/inbox/store.go) 同时写入 [Inbox 与视图](internal/inbox/schema.sql),两张表、17个中文字段注释、零外键。模型见 [model.go](internal/inbox/model.go)。
|
||||
|
||||
根目录 `make test/vet/build` 包含本模块;`make integration` 创建独立临时数据库,运行数据库、COMMIT响应丢失、真实消费者强杀回归和demo,结束清理自建容器。未设置集成环境时普通go test跳过外部测试,不表示这些故障已经验证。
|
||||
210
examples/result-inbox/cmd/receiver/main.go
Normal file
210
examples/result-inbox/cmd/receiver/main.go
Normal file
@@ -0,0 +1,210 @@
|
||||
// receiver 使用独立业务数据库演示结果Inbox与版本化业务视图。
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"git.yuntuo.hk/k2safe/orbit/examples/result-inbox/internal/inbox"
|
||||
orbit "git.yuntuo.hk/k2safe/orbit/sdk/go"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
func run() error {
|
||||
if len(os.Args) != 2 || (os.Args[1] != "init" && os.Args[1] != "consume" && os.Args[1] != "demo") {
|
||||
return errors.New("usage: receiver init|consume|demo")
|
||||
}
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
open, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
db, err := inbox.Open(open, os.Getenv("ORBIT_RECEIVER_DSN"))
|
||||
cancel()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
if os.Args[1] == "init" {
|
||||
bounded, stop := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer stop()
|
||||
if err = inbox.Init(bounded, db); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println("receiver_schema_initialized=true foreign_keys=0")
|
||||
return nil
|
||||
}
|
||||
api, queue, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name := os.Getenv("RECEIVER_NAME")
|
||||
if name == "" {
|
||||
name = "task-result-view"
|
||||
}
|
||||
store, err := inbox.New(db, name, queue)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if os.Args[1] == "demo" {
|
||||
return demo(ctx, api, store, queue)
|
||||
}
|
||||
check, checkStop := context.WithTimeout(ctx, 5*time.Second)
|
||||
subscription, err := api.GetSubscription(check, os.Getenv("ORBIT_SUBSCRIPTION_ID"))
|
||||
checkStop()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if subscription.QueueID != queue {
|
||||
return errors.New("subscription does not match configured receiver queue")
|
||||
}
|
||||
consumer, err := orbit.NewEventConsumer(api, orbit.EventConsumerOptions{SubscriptionID: subscription.ID, Capacity: 4, Handle: func(work context.Context, event orbit.ResultEvent) error {
|
||||
err := store.Handle(work, event)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "receiver_event=%s error=%v\n", event.ID, err)
|
||||
}
|
||||
return err
|
||||
}, ShutdownTimeout: 10 * time.Second})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
observed := make(chan struct{})
|
||||
go func() {
|
||||
defer close(observed)
|
||||
for event := range consumer.Events() {
|
||||
fmt.Printf("consumer_event=%s event_id=%s code=%s\n", event.Type, event.EventID, event.Code)
|
||||
}
|
||||
}()
|
||||
err = consumer.Run(ctx)
|
||||
<-observed
|
||||
return err
|
||||
}
|
||||
func client() (*orbit.Client, string, error) {
|
||||
token, queue := os.Getenv("ORBIT_TOKEN"), os.Getenv("ORBIT_QUEUE_ID")
|
||||
if path := os.Getenv("ORBIT_CREDENTIALS_FILE"); path != "" {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, "", errors.New("cannot read credential file")
|
||||
}
|
||||
var c struct {
|
||||
Token string `json:"token"`
|
||||
QueueID string `json:"queue_id"`
|
||||
}
|
||||
if json.Unmarshal(raw, &c) != nil {
|
||||
return nil, "", errors.New("invalid credential file")
|
||||
}
|
||||
if token == "" {
|
||||
token = c.Token
|
||||
}
|
||||
if queue == "" {
|
||||
queue = c.QueueID
|
||||
}
|
||||
}
|
||||
if token == "" || queue == "" {
|
||||
return nil, "", errors.New("event-enabled credential and queue are required")
|
||||
}
|
||||
c, err := orbit.NewClient(os.Getenv("ORBIT_URL"), orbit.Options{Token: token})
|
||||
return c, queue, err
|
||||
}
|
||||
func demo(parent context.Context, api *orbit.Client, store *inbox.Store, queue string) error {
|
||||
ctx, stop := context.WithTimeout(parent, 20*time.Second)
|
||||
defer stop()
|
||||
id, err := orbit.NewEventMessageID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
created, err := api.CreateSubscription(ctx, "inbox-sub-"+id, orbit.CreateSubscriptionRequest{Name: "inbox-" + id, QueueID: queue, Types: []string{"task.cancelled"}, MaxPending: 10, MaxAttempts: 3})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
task, err := api.SubmitTask(ctx, "inbox-task-"+id, orbit.SubmitTaskRequest{QueueID: queue, Handler: "object.prepare", HandlerVersion: "v1", PayloadVersion: "v1", Payload: json.RawMessage(`{}`)})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = api.CancelTask(ctx, task.Task.ID, "inbox-cancel-"+id, orbit.CancelTaskRequest{ExpectedRevision: task.Task.Revision, Reason: "durable inbox example"}); err != nil {
|
||||
return err
|
||||
}
|
||||
duplicate := make(chan bool, 1)
|
||||
consumer, err := orbit.NewEventConsumer(api, orbit.EventConsumerOptions{SubscriptionID: created.Subscription.ID, Capacity: 1, ShutdownTimeout: 5 * time.Second, Handle: func(work context.Context, event orbit.ResultEvent) error {
|
||||
if event.Snapshot.TaskID != task.Task.ID {
|
||||
return inbox.ErrInvalidEvent
|
||||
}
|
||||
if _, err := store.Apply(work, event); err != nil {
|
||||
return err
|
||||
}
|
||||
// 再用同一冻结事件进入业务事务,证明重复不会再次推进视图;真实进程故障另有回归。
|
||||
again, err := store.Apply(work, event)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !again.Duplicate {
|
||||
return errors.New("receiver duplicate was not recognized")
|
||||
}
|
||||
select {
|
||||
case duplicate <- true:
|
||||
default:
|
||||
}
|
||||
return nil
|
||||
}})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
run, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- consumer.Run(run) }()
|
||||
confirmed := false
|
||||
for !confirmed {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
cancel()
|
||||
<-done
|
||||
return errors.New("inbox demo timed out before confirmed acknowledgement")
|
||||
case err := <-done:
|
||||
if err == nil {
|
||||
err = errors.New("consumer stopped before acknowledgement")
|
||||
}
|
||||
return err
|
||||
case event, ok := <-consumer.Events():
|
||||
if !ok {
|
||||
if err := <-done; err != nil {
|
||||
return err
|
||||
}
|
||||
return errors.New("consumer stopped before acknowledgement")
|
||||
}
|
||||
confirmed = event.Type == "acknowledged"
|
||||
}
|
||||
}
|
||||
cancel()
|
||||
if err = <-done; err != nil {
|
||||
return err
|
||||
}
|
||||
select {
|
||||
case <-duplicate:
|
||||
default:
|
||||
return errors.New("duplicate verification missing")
|
||||
}
|
||||
view, err := store.View(ctx, task.Task.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if view.AppliedEvents != 1 || view.LastState != "CANCELLED" {
|
||||
return errors.New("duplicate changed business projection")
|
||||
}
|
||||
paused, err := api.ConfigureSubscription(ctx, created.Subscription.ID, "inbox-pause-"+id, orbit.ConfigureSubscriptionRequest{ExpectedRevision: created.Subscription.Revision, Enabled: false, Reason: "durable inbox example completed"})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("task=%s state=%s inbox_duplicate=true applied_events=%d\n", task.Task.ID, view.LastState, view.AppliedEvents)
|
||||
fmt.Printf("ack_confirmed=%d pending=%d subscription_enabled=%t\n", consumer.Stats().Acknowledged, paused.Subscription.PendingCount, paused.Subscription.Enabled)
|
||||
return nil
|
||||
}
|
||||
15
examples/result-inbox/go.mod
Normal file
15
examples/result-inbox/go.mod
Normal file
@@ -0,0 +1,15 @@
|
||||
module git.yuntuo.hk/k2safe/orbit/examples/result-inbox
|
||||
|
||||
go 1.27.1
|
||||
|
||||
require (
|
||||
git.yuntuo.hk/k2safe/orbit/sdk/go v0.0.0
|
||||
github.com/go-sql-driver/mysql v1.10.1
|
||||
)
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.2.0 // indirect
|
||||
github.com/coder/websocket v1.8.15 // indirect
|
||||
)
|
||||
|
||||
replace git.yuntuo.hk/k2safe/orbit/sdk/go => ../../sdk/go
|
||||
6
examples/result-inbox/go.sum
Normal file
6
examples/result-inbox/go.sum
Normal file
@@ -0,0 +1,6 @@
|
||||
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||
github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
|
||||
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
||||
github.com/go-sql-driver/mysql v1.10.1 h1:arlSnNLq6a5yxGxV7qg9lF4j0C+KwD6NbQyKr9QL6ME=
|
||||
github.com/go-sql-driver/mysql v1.10.1/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk=
|
||||
@@ -0,0 +1,184 @@
|
||||
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")
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
package inbox
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
orbit "git.yuntuo.hk/k2safe/orbit/sdk/go"
|
||||
)
|
||||
|
||||
type receiverMarker struct {
|
||||
Phase string `json:"phase"`
|
||||
EventID string `json:"event_id"`
|
||||
Duplicate bool `json:"duplicate"`
|
||||
}
|
||||
|
||||
func receiverClient(t *testing.T) *orbit.Client {
|
||||
t.Helper()
|
||||
base, path := os.Getenv("ORBIT_TEST_RECEIVER_URL"), os.Getenv("ORBIT_TEST_RECEIVER_CREDENTIALS_FILE")
|
||||
if base == "" || path == "" {
|
||||
t.Skip("requires isolated real Orbit process and receiver credential")
|
||||
}
|
||||
if !strings.HasPrefix(base, "http://127.0.0.1:") {
|
||||
t.Fatal("receiver process integration requires loopback Orbit")
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal("cannot read test credential")
|
||||
}
|
||||
var credentials struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if json.Unmarshal(raw, &credentials) != nil || credentials.Token == "" {
|
||||
t.Fatal("invalid test credential")
|
||||
}
|
||||
c, err := orbit.NewClient(base, orbit.Options{Token: credentials.Token})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// 此入口只存在于测试二进制。处理器使用正式Store和Consumer,故障屏障位于Apply提交后、返回nil前。
|
||||
func TestReceiverProcessHelper(t *testing.T) {
|
||||
mode := os.Getenv("ORBIT_TEST_RECEIVER_CHILD")
|
||||
if mode == "" {
|
||||
t.Skip("subprocess helper")
|
||||
}
|
||||
if mode != "hold_after_commit" && mode != "confirm_duplicate" {
|
||||
t.Fatal("invalid receiver child mode")
|
||||
}
|
||||
db, _ := integrationDB(t)
|
||||
api := receiverClient(t)
|
||||
s, err := New(db, os.Getenv("ORBIT_TEST_RECEIVER_NAME"), os.Getenv("ORBIT_TEST_RECEIVER_QUEUE"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, stop := context.WithTimeout(context.Background(), 45*time.Second)
|
||||
defer stop()
|
||||
consumer, err := orbit.NewEventConsumer(api, orbit.EventConsumerOptions{SubscriptionID: os.Getenv("ORBIT_TEST_RECEIVER_SUBSCRIPTION"), Capacity: 1, ShutdownTimeout: time.Second, Handle: func(work context.Context, event orbit.ResultEvent) error {
|
||||
result, err := s.Apply(work, event)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
marker, _ := json.Marshal(receiverMarker{Phase: "committed", EventID: event.ID, Duplicate: result.Duplicate})
|
||||
fmt.Println("ORBIT_RECEIVER_TEST " + string(marker))
|
||||
if mode == "hold_after_commit" {
|
||||
<-work.Done()
|
||||
return work.Err()
|
||||
}
|
||||
if !result.Duplicate {
|
||||
return errors.New("restarted receiver did not find committed inbox")
|
||||
}
|
||||
return nil
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
run, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- consumer.Run(run) }()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
cancel()
|
||||
<-done
|
||||
t.Fatal("receiver helper timed out")
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return
|
||||
case event, ok := <-consumer.Events():
|
||||
if !ok {
|
||||
if err := <-done; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if event.Type == "acknowledged" {
|
||||
marker, _ := json.Marshal(receiverMarker{Phase: "acknowledged", EventID: event.EventID})
|
||||
fmt.Println("ORBIT_RECEIVER_TEST " + string(marker))
|
||||
cancel()
|
||||
if err := <-done; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type receiverChild struct {
|
||||
cmd *exec.Cmd
|
||||
done chan struct{}
|
||||
waitErr error
|
||||
markers chan receiverMarker
|
||||
}
|
||||
|
||||
func receiverEnv(values map[string]string) []string {
|
||||
out := []string{}
|
||||
for _, entry := range os.Environ() {
|
||||
key, _, _ := strings.Cut(entry, "=")
|
||||
if _, found := values[key]; !found {
|
||||
out = append(out, entry)
|
||||
}
|
||||
}
|
||||
for key, value := range values {
|
||||
out = append(out, key+"="+value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
func startReceiverChild(t *testing.T, s *Store, sub, mode string) *receiverChild {
|
||||
t.Helper()
|
||||
binary, err := os.Executable()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cmd := exec.Command(binary, "-test.run=^TestReceiverProcessHelper$")
|
||||
cmd.Env = receiverEnv(map[string]string{"ORBIT_TEST_RECEIVER_CHILD": mode, "ORBIT_TEST_RECEIVER_NAME": s.consumer, "ORBIT_TEST_RECEIVER_QUEUE": s.queue, "ORBIT_TEST_RECEIVER_SUBSCRIPTION": sub})
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cmd.Stderr = io.Discard
|
||||
p := &receiverChild{cmd: cmd, done: make(chan struct{}), markers: make(chan receiverMarker, 8)}
|
||||
if err = cmd.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
readDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(readDone)
|
||||
scanner := bufio.NewScanner(stdout)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if !strings.HasPrefix(line, "ORBIT_RECEIVER_TEST ") {
|
||||
continue
|
||||
}
|
||||
var marker receiverMarker
|
||||
if json.Unmarshal([]byte(strings.TrimPrefix(line, "ORBIT_RECEIVER_TEST ")), &marker) == nil {
|
||||
select {
|
||||
case p.markers <- marker:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
go func() { <-readDone; p.waitErr = cmd.Wait(); close(p.done) }()
|
||||
t.Cleanup(func() {
|
||||
select {
|
||||
case <-p.done:
|
||||
return
|
||||
default:
|
||||
}
|
||||
_ = cmd.Process.Kill()
|
||||
select {
|
||||
case <-p.done:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Error("owned receiver child failed to exit")
|
||||
}
|
||||
})
|
||||
return p
|
||||
}
|
||||
func (p *receiverChild) marker(t *testing.T, ctx context.Context, phase string) receiverMarker {
|
||||
t.Helper()
|
||||
for {
|
||||
select {
|
||||
case marker := <-p.markers:
|
||||
if marker.Phase == phase {
|
||||
return marker
|
||||
}
|
||||
case <-p.done:
|
||||
select {
|
||||
case marker := <-p.markers:
|
||||
if marker.Phase == phase {
|
||||
return marker
|
||||
}
|
||||
default:
|
||||
}
|
||||
t.Fatal("receiver child exited before expected boundary", p.waitErr)
|
||||
case <-ctx.Done():
|
||||
t.Fatal("receiver child did not reach " + phase)
|
||||
}
|
||||
}
|
||||
}
|
||||
func (p *receiverChild) kill(t *testing.T) {
|
||||
t.Helper()
|
||||
select {
|
||||
case <-p.done:
|
||||
t.Fatal("receiver exited before fault injection")
|
||||
default:
|
||||
}
|
||||
if err := p.cmd.Process.Kill(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case <-p.done:
|
||||
var exit *exec.ExitError
|
||||
if !errors.As(p.waitErr, &exit) || exit.ExitCode() != -1 {
|
||||
t.Fatal("receiver did not terminate by signal")
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("receiver SIGKILL was not confirmed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationReceiverProcessCommitBeforeAck(t *testing.T) {
|
||||
db, _ := integrationDB(t)
|
||||
api := receiverClient(t)
|
||||
ctx, stop := context.WithTimeout(context.Background(), 45*time.Second)
|
||||
defer stop()
|
||||
id := newID(t)
|
||||
queue, err := api.CreateQueue(ctx, "queue-"+id, orbit.CreateQueueRequest{Name: "inbox-process-" + id, MaxConcurrency: 1, RatePerSecond: 10})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s, err := New(db, "restart-"+id, queue.Queue.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
created, err := api.CreateSubscription(ctx, "sub-"+id, orbit.CreateSubscriptionRequest{Name: "inbox-process-" + id, QueueID: queue.Queue.ID, Types: []string{"task.cancelled"}, MaxPending: 1, MaxAttempts: 3})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
task, err := api.SubmitTask(ctx, "task-"+id, orbit.SubmitTaskRequest{QueueID: queue.Queue.ID, Handler: "object.prepare", HandlerVersion: "v1", PayloadVersion: "v1", Payload: json.RawMessage(`{}`)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = api.CancelTask(ctx, task.Task.ID, "cancel-"+id, orbit.CancelTaskRequest{ExpectedRevision: task.Task.Revision, Reason: "receiver process fault"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first := startReceiverChild(t, s, created.Subscription.ID, "hold_after_commit")
|
||||
committed := first.marker(t, ctx, "committed")
|
||||
if committed.Duplicate {
|
||||
t.Fatal("first receiver unexpectedly deduplicated")
|
||||
}
|
||||
view, err := s.View(ctx, task.Task.ID)
|
||||
if err != nil || view.AppliedEvents != 1 || inboxCount(t, ctx, s) != 1 {
|
||||
t.Fatal("receiver barrier occurred before durable commit", err)
|
||||
}
|
||||
before, err := api.ListEventDeliveries(ctx, created.Subscription.ID, orbit.DeliveryPageRequest{})
|
||||
if err != nil || len(before.Items) != 1 || before.Items[0].State != orbit.DeliveryLeased || before.Items[0].AcknowledgedAt != nil {
|
||||
t.Fatal("fault boundary is not before ACK", err)
|
||||
}
|
||||
first.kill(t)
|
||||
second := startReceiverChild(t, s, created.Subscription.ID, "confirm_duplicate")
|
||||
duplicate := second.marker(t, ctx, "committed")
|
||||
ack := second.marker(t, ctx, "acknowledged")
|
||||
if !duplicate.Duplicate || duplicate.EventID != committed.EventID || ack.EventID != committed.EventID {
|
||||
t.Fatal("new process did not reconcile original event")
|
||||
}
|
||||
select {
|
||||
case <-second.done:
|
||||
if second.waitErr != nil {
|
||||
t.Fatal("replacement receiver failed", second.waitErr)
|
||||
}
|
||||
case <-ctx.Done():
|
||||
t.Fatal("replacement receiver did not exit cleanly")
|
||||
}
|
||||
after, err := s.View(ctx, task.Task.ID)
|
||||
if err != nil || after.AppliedEvents != 1 || inboxCount(t, ctx, s) != 1 {
|
||||
t.Fatal("business effect repeated after consumer restart", err)
|
||||
}
|
||||
confirmed, err := api.ListEventDeliveries(ctx, created.Subscription.ID, orbit.DeliveryPageRequest{State: orbit.DeliveryAcked})
|
||||
if err != nil || len(confirmed.Items) != 1 || confirmed.Items[0].Attempts != 2 || confirmed.Items[0].ID != before.Items[0].ID {
|
||||
t.Fatal("delivery recovery or acknowledgement history wrong", err)
|
||||
}
|
||||
t.Log("business commit verified before ACK; consumer SIGKILL confirmed, new process used durable Inbox after real lease expiry; one business effect and two delivery attempts")
|
||||
}
|
||||
28
examples/result-inbox/internal/inbox/model.go
Normal file
28
examples/result-inbox/internal/inbox/model.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package inbox
|
||||
|
||||
import "time"
|
||||
|
||||
// Record 对应receiver_inbox,与业务视图同事务提交;存在记录才表示该事件处理已提交。
|
||||
type Record struct {
|
||||
ConsumerName string // ConsumerName 固定用途名;进程重启及多实例必须复用。
|
||||
QueueID string // QueueID 固定来源队列,不能从连接会话推导。
|
||||
EventID string // EventID 稳定事件身份,不使用交付ID或代次。
|
||||
EventDigest []byte // EventDigest 32字节SHA256,防止同事件身份被换成不同内容。
|
||||
EventJSON []byte // EventJSON 规范化冻结事件,含业务数据、不含租约秘密。
|
||||
Applied bool // Applied 该事件是否推进业务视图;旧版本为false但仍可靠保存。
|
||||
ReceivedAt time.Time // ReceivedAt 数据库UTC受理时间,微秒精度。
|
||||
}
|
||||
|
||||
// TaskView 对应receiver_task_views;这是接收方业务数据,不是Orbit任务权威状态。
|
||||
type TaskView struct {
|
||||
ConsumerName string // ConsumerName 与Inbox相同的固定用途。
|
||||
QueueID string // QueueID 与Inbox相同的绑定队列。
|
||||
TaskID string // TaskID 原任务标识,业务视图主键的一部分。
|
||||
LastRevision uint64 // LastRevision 已应用的最大任务版本;0只允许在未提交事务的占位行中。
|
||||
LastEventID *string // LastEventID 最后推进视图的事件;仅占位状态可为空。
|
||||
LastState string // LastState 冻结结果状态;NEEDS_REVIEW不能作为成功或最终失败。
|
||||
ResultJSON []byte // ResultJSON 当前结果,nil表示来源未提供;数值保持精确。
|
||||
AppliedEvents uint64 // AppliedEvents 实际推进次数,重复及迟到旧版本不增加。
|
||||
CreatedAt time.Time // CreatedAt 视图首次创建的UTC时间,微秒精度。
|
||||
UpdatedAt time.Time // UpdatedAt 最后推进的UTC时间,微秒精度。
|
||||
}
|
||||
32
examples/result-inbox/internal/inbox/schema.sql
Normal file
32
examples/result-inbox/internal/inbox/schema.sql
Normal file
@@ -0,0 +1,32 @@
|
||||
-- 接收方独立数据库的初始示例表;不属于Orbit平台迁移,不创建外键或级联删除。
|
||||
CREATE TABLE receiver_inbox (
|
||||
consumer_name VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL COMMENT '固定消费者用途;同一用途重启或多实例必须复用',
|
||||
queue_id CHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL COMMENT '接收方绑定的队列标识;隔离不同来源范围',
|
||||
event_id CHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL COMMENT '原事件标识;跨连接及交付代次保持不变',
|
||||
event_digest BINARY(32) NOT NULL COMMENT '规范化冻结事件JSON的SHA256;同身份不同内容拒绝确认',
|
||||
event_json MEDIUMTEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL COMMENT '冻结事件及业务结果;包含业务数据须限制访问,不含租约证明',
|
||||
applied BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否推进业务视图;迟到的旧版本只保留Inbox历史',
|
||||
received_at DATETIME(6) NOT NULL COMMENT '本次业务事务受理事件的UTC时间,微秒精度',
|
||||
PRIMARY KEY (consumer_name,queue_id,event_id),
|
||||
CONSTRAINT ck_receiver_event_json CHECK (JSON_VALID(event_json)),
|
||||
CONSTRAINT ck_receiver_applied CHECK (applied IN (0,1))
|
||||
) ENGINE=InnoDB COMMENT='事务Inbox;去重记录与业务视图一起提交,禁止独立预写成功标志';
|
||||
|
||||
CREATE TABLE receiver_task_views (
|
||||
consumer_name VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL COMMENT '固定消费者用途;与Inbox使用相同范围',
|
||||
queue_id CHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL COMMENT '固定队列来源;由处理器事务前验证归属',
|
||||
task_id CHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL COMMENT '原任务标识;每个任务建立一份版本化业务视图',
|
||||
last_revision BIGINT UNSIGNED NOT NULL COMMENT '已应用最大任务版本;0仅为同事务中尚未更新的占位状态',
|
||||
last_event_id CHAR(32) CHARACTER SET ascii COLLATE ascii_bin NULL COMMENT '推进当前视图的事件;占位行为空,提交后的有效视图非空',
|
||||
last_state VARCHAR(24) CHARACTER SET ascii COLLATE ascii_bin NOT NULL COMMENT '冻结任务结果状态;UNINITIALIZED只用于同事务内占位',
|
||||
result_json MEDIUMTEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL COMMENT '当前结果JSON;来源未提供结果时为空,保留精确数字',
|
||||
applied_events BIGINT UNSIGNED NOT NULL COMMENT '实际推进视图的事件次数;重复和旧版本不增加',
|
||||
created_at DATETIME(6) NOT NULL COMMENT '视图首次建立的UTC时间,微秒精度',
|
||||
updated_at DATETIME(6) NOT NULL COMMENT '视图最后推进的UTC时间,微秒精度',
|
||||
PRIMARY KEY (consumer_name,queue_id,task_id),
|
||||
CONSTRAINT ck_receiver_revision CHECK (
|
||||
(last_revision=0 AND last_event_id IS NULL AND last_state='UNINITIALIZED' AND applied_events=0)
|
||||
OR (last_revision>0 AND last_event_id IS NOT NULL AND applied_events>0 AND last_state IN ('SUCCEEDED','FAILED','CANCELLED','NEEDS_REVIEW'))
|
||||
),
|
||||
CONSTRAINT ck_receiver_result CHECK (result_json IS NULL OR JSON_VALID(result_json))
|
||||
) ENGINE=InnoDB COMMENT='业务侧任务结果视图;锁定单任务行后仅接受更高版本,防止迟到结果倒退';
|
||||
314
examples/result-inbox/internal/inbox/store.go
Normal file
314
examples/result-inbox/internal/inbox/store.go
Normal file
@@ -0,0 +1,314 @@
|
||||
// Package inbox 演示业务端同事务去重和版本化结果视图,不是通用业务恰好一次框架。
|
||||
package inbox
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
orbit "git.yuntuo.hk/k2safe/orbit/sdk/go"
|
||||
"github.com/go-sql-driver/mysql"
|
||||
)
|
||||
|
||||
//go:embed schema.sql
|
||||
var schema string
|
||||
|
||||
var (
|
||||
ErrInvalidEvent = errors.New("receiver event or scope is invalid")
|
||||
ErrEventConflict = errors.New("receiver event identity has conflicting content; investigate before acknowledgement")
|
||||
ErrRevisionConflict = errors.New("receiver task revision has conflicting event identities")
|
||||
ErrStorage = errors.New("receiver storage operation failed; event remains unacknowledged")
|
||||
ErrCommitUnconfirmed = errors.New("receiver transaction commit is unconfirmed; reconcile original event on redelivery")
|
||||
namePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`)
|
||||
idPattern = regexp.MustCompile(`^[a-f0-9]{32}$`)
|
||||
)
|
||||
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
consumer, queue string
|
||||
}
|
||||
type Result struct {
|
||||
Duplicate bool // Duplicate 当前事件已在相同消费者范围内提交,内容摘要也相同。
|
||||
Applied bool // Applied 原事务是否推进视图;重复命中时返回历史值,不表示再次推进。
|
||||
}
|
||||
|
||||
// Open 仅连接接收方数据库;密码和原始DSN不进入错误输出,不自动创建或修改表。
|
||||
func Open(ctx context.Context, dsn string) (*sql.DB, error) {
|
||||
cfg, err := mysql.ParseDSN(dsn)
|
||||
if err != nil || cfg.DBName == "" {
|
||||
return nil, errors.New("valid receiver MySQL DSN with explicit database is required")
|
||||
}
|
||||
cfg.ParseTime = true
|
||||
cfg.Loc = time.UTC
|
||||
cfg.MultiStatements = false
|
||||
cfg.AllowAllFiles = false
|
||||
cfg.Timeout = 5 * time.Second
|
||||
cfg.ReadTimeout = 8 * time.Second
|
||||
cfg.WriteTimeout = 8 * time.Second
|
||||
if cfg.Params == nil {
|
||||
cfg.Params = map[string]string{}
|
||||
}
|
||||
cfg.Params["time_zone"] = "'+00:00'"
|
||||
connector, err := mysql.NewConnector(cfg)
|
||||
if err != nil {
|
||||
return nil, ErrStorage
|
||||
}
|
||||
db := sql.OpenDB(connector)
|
||||
db.SetMaxOpenConns(8)
|
||||
db.SetMaxIdleConns(4)
|
||||
db.SetConnMaxLifetime(5 * time.Minute)
|
||||
if err = db.PingContext(ctx); err != nil {
|
||||
db.Close()
|
||||
return nil, ErrStorage
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// Init 初始化专用空数据库。已有表会报错;升级已有业务数据库应使用接收方自己的迁移。
|
||||
func Init(ctx context.Context, db *sql.DB) error {
|
||||
for _, statement := range strings.Split(schema, ";") {
|
||||
if strings.TrimSpace(statement) == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, statement); err != nil {
|
||||
return ErrStorage
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func New(db *sql.DB, consumer, queue string) (*Store, error) {
|
||||
if db == nil || !namePattern.MatchString(consumer) || !idPattern.MatchString(queue) {
|
||||
return nil, ErrInvalidEvent
|
||||
}
|
||||
return &Store{db: db, consumer: consumer, queue: queue}, nil
|
||||
}
|
||||
|
||||
// Handle 可直接作为EventConsumer处理器;只有事务确认提交或命中相同已提交记录才返回nil。
|
||||
func (s *Store) Handle(ctx context.Context, event orbit.ResultEvent) error {
|
||||
_, err := s.Apply(ctx, event)
|
||||
return err
|
||||
}
|
||||
|
||||
// Apply 不含网络副作用。事务失败或COMMIT响应未知时不允许SDK发送ACK。
|
||||
func (s *Store) Apply(parent context.Context, event orbit.ResultEvent) (Result, error) {
|
||||
ctx, stop := context.WithTimeout(parent, 5*time.Second)
|
||||
defer stop()
|
||||
raw, digest, err := s.document(event)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
|
||||
if err != nil {
|
||||
return Result{}, ErrStorage
|
||||
}
|
||||
defer tx.Rollback()
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO receiver_inbox(consumer_name,queue_id,event_id,event_digest,event_json,applied,received_at) VALUES(?,?,?,?,?,FALSE,UTC_TIMESTAMP(6))`, s.consumer, s.queue, event.ID, digest[:], raw)
|
||||
if err != nil {
|
||||
var duplicate *mysql.MySQLError
|
||||
if !errors.As(err, &duplicate) || duplicate.Number != 1062 {
|
||||
return Result{}, ErrStorage
|
||||
}
|
||||
// 唯一键冲突已等待胜出事务完成。结束本事务,再核对已提交记录;不能忽略其他SQL错误。
|
||||
if err = tx.Rollback(); err != nil {
|
||||
return Result{}, ErrStorage
|
||||
}
|
||||
var saved []byte
|
||||
var applied bool
|
||||
if err = s.db.QueryRowContext(ctx, `SELECT event_digest,applied FROM receiver_inbox WHERE consumer_name=? AND queue_id=? AND event_id=?`, s.consumer, s.queue, event.ID).Scan(&saved, &applied); err != nil {
|
||||
return Result{}, ErrStorage
|
||||
}
|
||||
if !bytes.Equal(saved, digest[:]) {
|
||||
return Result{}, ErrEventConflict
|
||||
}
|
||||
return Result{Duplicate: true, Applied: applied}, nil
|
||||
}
|
||||
// 原子建立或取得单任务行锁,不依赖客户端RowsAffected选项判断新旧。
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO receiver_task_views(consumer_name,queue_id,task_id,last_revision,last_state,applied_events,created_at,updated_at) VALUES(?,?,?,0,'UNINITIALIZED',0,UTC_TIMESTAMP(6),UTC_TIMESTAMP(6)) ON DUPLICATE KEY UPDATE task_id=receiver_task_views.task_id`, s.consumer, s.queue, event.AggregateID)
|
||||
if err != nil {
|
||||
return Result{}, ErrStorage
|
||||
}
|
||||
var revision uint64
|
||||
if err = tx.QueryRowContext(ctx, `SELECT last_revision FROM receiver_task_views WHERE consumer_name=? AND queue_id=? AND task_id=? FOR UPDATE`, s.consumer, s.queue, event.AggregateID).Scan(&revision); err != nil {
|
||||
return Result{}, ErrStorage
|
||||
}
|
||||
if event.AggregateRevision == revision {
|
||||
return Result{}, ErrRevisionConflict
|
||||
}
|
||||
applied := event.AggregateRevision > revision
|
||||
if applied {
|
||||
var result any
|
||||
if len(event.Snapshot.Result) > 0 {
|
||||
result = []byte(event.Snapshot.Result)
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `UPDATE receiver_task_views SET last_revision=?,last_event_id=?,last_state=?,result_json=?,applied_events=applied_events+1,updated_at=UTC_TIMESTAMP(6) WHERE consumer_name=? AND queue_id=? AND task_id=?`, event.AggregateRevision, event.ID, string(event.Snapshot.State), result, s.consumer, s.queue, event.AggregateID)
|
||||
if err != nil {
|
||||
return Result{}, ErrStorage
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `UPDATE receiver_inbox SET applied=TRUE WHERE consumer_name=? AND queue_id=? AND event_id=?`, s.consumer, s.queue, event.ID); err != nil {
|
||||
return Result{}, ErrStorage
|
||||
}
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return Result{}, ErrCommitUnconfirmed
|
||||
}
|
||||
return Result{Applied: applied}, nil
|
||||
}
|
||||
|
||||
func (s *Store) document(event orbit.ResultEvent) ([]byte, [32]byte, error) {
|
||||
var none [32]byte
|
||||
snapshot := event.Snapshot
|
||||
if len(event.Details)+len(snapshot.Result) > 512<<10 {
|
||||
return nil, none, ErrInvalidEvent
|
||||
}
|
||||
if !idPattern.MatchString(event.ID) || event.Sequence == 0 || event.AggregateKind != "task" || !idPattern.MatchString(event.AggregateID) || snapshot.TaskID != event.AggregateID || snapshot.QueueID != s.queue || event.AggregateRevision == 0 || event.AggregateRevision != snapshot.Revision || event.OccurredAt.IsZero() || snapshot.SchemaVersion != 1 || snapshot.CurrentAttemptID != nil && !idPattern.MatchString(*snapshot.CurrentAttemptID) {
|
||||
return nil, none, ErrInvalidEvent
|
||||
}
|
||||
states := map[string]orbit.TaskState{"task.succeeded": orbit.TaskSucceeded, "task.failed": orbit.TaskFailed, "task.cancelled": orbit.TaskCancelled, "task.needs_review": orbit.TaskNeedsReview}
|
||||
if state, ok := states[event.Type]; !ok || state != snapshot.State {
|
||||
return nil, none, ErrInvalidEvent
|
||||
}
|
||||
if len(bytes.TrimSpace(event.Details)) == 0 || bytes.TrimSpace(event.Details)[0] != '{' {
|
||||
return nil, none, ErrInvalidEvent
|
||||
}
|
||||
event.OccurredAt = event.OccurredAt.UTC()
|
||||
var buffer bytes.Buffer
|
||||
encoder := json.NewEncoder(&buffer)
|
||||
encoder.SetEscapeHTML(false)
|
||||
if encoder.Encode(event) != nil || buffer.Len() > 512<<10 {
|
||||
return nil, none, ErrInvalidEvent
|
||||
}
|
||||
// 对象键与空白规范化,数字使用json.Number保留原精度及十进制表达。
|
||||
canonical, err := canonicalJSON(buffer.Bytes())
|
||||
if err != nil {
|
||||
return nil, none, err
|
||||
}
|
||||
return canonical, sha256.Sum256(canonical), nil
|
||||
}
|
||||
func canonicalJSON(raw []byte) ([]byte, error) {
|
||||
if !utf8.Valid(raw) || !json.Valid(raw) || !pairedUnicode(raw) {
|
||||
return nil, ErrInvalidEvent
|
||||
}
|
||||
d := json.NewDecoder(bytes.NewReader(raw))
|
||||
d.UseNumber()
|
||||
var value func(int) (any, error)
|
||||
value = func(depth int) (any, error) {
|
||||
if depth > 32 {
|
||||
return nil, ErrInvalidEvent
|
||||
}
|
||||
token, err := d.Token()
|
||||
if err != nil {
|
||||
return nil, ErrInvalidEvent
|
||||
}
|
||||
switch token {
|
||||
case json.Delim('{'):
|
||||
out := map[string]any{}
|
||||
for d.More() {
|
||||
key, err := d.Token()
|
||||
if err != nil {
|
||||
return nil, ErrInvalidEvent
|
||||
}
|
||||
name, ok := key.(string)
|
||||
if !ok {
|
||||
return nil, ErrInvalidEvent
|
||||
}
|
||||
if _, found := out[name]; found {
|
||||
return nil, ErrInvalidEvent
|
||||
}
|
||||
out[name], err = value(depth + 1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
_, err = d.Token()
|
||||
return out, err
|
||||
case json.Delim('['):
|
||||
out := []any{}
|
||||
for d.More() {
|
||||
item, err := value(depth + 1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
_, err = d.Token()
|
||||
return out, err
|
||||
default:
|
||||
return token, nil
|
||||
}
|
||||
}
|
||||
doc, err := value(0)
|
||||
if err != nil {
|
||||
return nil, ErrInvalidEvent
|
||||
}
|
||||
if _, err = d.Token(); err != io.EOF {
|
||||
return nil, ErrInvalidEvent
|
||||
}
|
||||
var out bytes.Buffer
|
||||
e := json.NewEncoder(&out)
|
||||
e.SetEscapeHTML(false)
|
||||
if e.Encode(doc) != nil {
|
||||
return nil, ErrInvalidEvent
|
||||
}
|
||||
return bytes.TrimSuffix(out.Bytes(), []byte{'\n'}), nil
|
||||
}
|
||||
|
||||
// 不将未配对UTF-16转义静默转换成替代字符,避免摘要与实际业务文本失真。
|
||||
func pairedUnicode(raw []byte) bool {
|
||||
inside := false
|
||||
for i := 0; i < len(raw); i++ {
|
||||
if raw[i] == '"' {
|
||||
inside = !inside
|
||||
continue
|
||||
}
|
||||
if !inside || raw[i] != '\\' {
|
||||
continue
|
||||
}
|
||||
i++
|
||||
if i >= len(raw) {
|
||||
return false
|
||||
}
|
||||
if raw[i] != 'u' {
|
||||
continue
|
||||
}
|
||||
if i+4 >= len(raw) {
|
||||
return false
|
||||
}
|
||||
n, err := strconv.ParseUint(string(raw[i+1:i+5]), 16, 16)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
i += 4
|
||||
if n < 0xd800 || n > 0xdfff {
|
||||
continue
|
||||
}
|
||||
if n > 0xdbff || i+6 >= len(raw) || raw[i+1] != '\\' || raw[i+2] != 'u' {
|
||||
return false
|
||||
}
|
||||
low, err := strconv.ParseUint(string(raw[i+3:i+7]), 16, 16)
|
||||
if err != nil || low < 0xdc00 || low > 0xdfff {
|
||||
return false
|
||||
}
|
||||
i += 6
|
||||
}
|
||||
return !inside
|
||||
}
|
||||
|
||||
// View 返回业务侧当前视图;不会从Orbit重新拼装结果。
|
||||
func (s *Store) View(ctx context.Context, task string) (TaskView, error) {
|
||||
out := TaskView{ConsumerName: s.consumer, QueueID: s.queue, TaskID: task}
|
||||
err := s.db.QueryRowContext(ctx, `SELECT last_revision,last_event_id,last_state,result_json,applied_events,created_at,updated_at FROM receiver_task_views WHERE consumer_name=? AND queue_id=? AND task_id=?`, s.consumer, s.queue, task).Scan(&out.LastRevision, &out.LastEventID, &out.LastState, &out.ResultJSON, &out.AppliedEvents, &out.CreatedAt, &out.UpdatedAt)
|
||||
if err != nil {
|
||||
return TaskView{}, ErrStorage
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
187
examples/result-inbox/internal/inbox/store_integration_test.go
Normal file
187
examples/result-inbox/internal/inbox/store_integration_test.go
Normal file
@@ -0,0 +1,187 @@
|
||||
package inbox
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/go-sql-driver/mysql"
|
||||
)
|
||||
|
||||
func integrationDB(t *testing.T) (*sql.DB, string) {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("ORBIT_TEST_RECEIVER_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("requires dedicated disposable receiver database")
|
||||
}
|
||||
cfg, err := mysql.ParseDSN(dsn)
|
||||
if err != nil || cfg.DBName != "orbit_receiver_test" || !strings.HasPrefix(cfg.Addr, "127.0.0.1:") {
|
||||
t.Fatal("receiver integration must use dedicated disposable loopback database")
|
||||
}
|
||||
ctx, stop := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer stop()
|
||||
db, err := Open(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
return db, dsn
|
||||
}
|
||||
func testStore(t *testing.T, db *sql.DB) *Store {
|
||||
t.Helper()
|
||||
s, err := New(db, "test-"+newID(t), newID(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
func inboxCount(t *testing.T, ctx context.Context, s *Store) int {
|
||||
t.Helper()
|
||||
var count int
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM receiver_inbox WHERE consumer_name=? AND queue_id=?`, s.consumer, s.queue).Scan(&count); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return count
|
||||
}
|
||||
func TestIntegrationReceiverSchema(t *testing.T) {
|
||||
db, _ := integrationDB(t)
|
||||
ctx, stop := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer stop()
|
||||
var fk int
|
||||
if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM information_schema.REFERENTIAL_CONSTRAINTS WHERE CONSTRAINT_SCHEMA=DATABASE()`).Scan(&fk); err != nil || fk != 0 {
|
||||
t.Fatal("receiver foreign keys", fk, err)
|
||||
}
|
||||
rows, err := db.QueryContext(ctx, `SELECT COLUMN_COMMENT FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME IN ('receiver_inbox','receiver_task_views')`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
count := 0
|
||||
for rows.Next() {
|
||||
var comment string
|
||||
if err = rows.Scan(&comment); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.ContainsFunc(comment, func(r rune) bool { return unicode.Is(unicode.Han, r) }) {
|
||||
t.Fatal("receiver column missing Chinese comment")
|
||||
}
|
||||
count++
|
||||
}
|
||||
if rows.Err() != nil || count != 17 {
|
||||
t.Fatal("receiver model column count", count, rows.Err())
|
||||
}
|
||||
t.Log("receiver schema has two tables, 17 Chinese column comments and zero foreign keys")
|
||||
}
|
||||
func TestIntegrationReceiverConcurrentDuplicateAndConflict(t *testing.T) {
|
||||
db, _ := integrationDB(t)
|
||||
s := testStore(t, db)
|
||||
ctx, stop := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer stop()
|
||||
event := testEvent(t, s.queue)
|
||||
type outcome struct {
|
||||
result Result
|
||||
err error
|
||||
}
|
||||
results := make(chan outcome, 8)
|
||||
start := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
for range 8 {
|
||||
wg.Add(1)
|
||||
go func() { defer wg.Done(); <-start; result, err := s.Apply(ctx, event); results <- outcome{result, err} }()
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
close(results)
|
||||
duplicates := 0
|
||||
for result := range results {
|
||||
if result.err != nil {
|
||||
t.Fatal(result.err)
|
||||
}
|
||||
if result.result.Duplicate {
|
||||
duplicates++
|
||||
}
|
||||
}
|
||||
view, err := s.View(ctx, event.AggregateID)
|
||||
if err != nil || duplicates != 7 || view.AppliedEvents != 1 || view.LastRevision != event.AggregateRevision || inboxCount(t, ctx, s) != 1 {
|
||||
t.Fatal("concurrent duplicate changed business effect", duplicates, err)
|
||||
}
|
||||
event.Snapshot.Result = json.RawMessage(`{"tampered":true}`)
|
||||
if _, err = s.Apply(ctx, event); !errors.Is(err, ErrEventConflict) {
|
||||
t.Fatal("same identity different content acknowledged", err)
|
||||
}
|
||||
unchanged, err := s.View(ctx, event.AggregateID)
|
||||
if err != nil || string(unchanged.ResultJSON) != string(view.ResultJSON) || unchanged.AppliedEvents != 1 {
|
||||
t.Fatal("conflicting event changed view", err)
|
||||
}
|
||||
}
|
||||
func TestIntegrationReceiverLateVersionsAndIsolation(t *testing.T) {
|
||||
db, _ := integrationDB(t)
|
||||
s := testStore(t, db)
|
||||
ctx, stop := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer stop()
|
||||
event := testEvent(t, s.queue)
|
||||
if _, err := s.Apply(ctx, event); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
old := event
|
||||
old.ID = newID(t)
|
||||
old.AggregateRevision--
|
||||
old.Snapshot.Revision--
|
||||
old.Snapshot.Result = json.RawMessage(`{"older":true}`)
|
||||
result, err := s.Apply(ctx, old)
|
||||
if err != nil || result.Applied || result.Duplicate {
|
||||
t.Fatal("old version incorrectly applied", err)
|
||||
}
|
||||
view, err := s.View(ctx, event.AggregateID)
|
||||
if err != nil || view.LastRevision != event.AggregateRevision || view.AppliedEvents != 1 || inboxCount(t, ctx, s) != 2 {
|
||||
t.Fatal("late event reverted business state", err)
|
||||
}
|
||||
conflict := event
|
||||
conflict.ID = newID(t)
|
||||
if _, err = s.Apply(ctx, conflict); !errors.Is(err, ErrRevisionConflict) || inboxCount(t, ctx, s) != 2 {
|
||||
t.Fatal("same task revision conflict persisted", err)
|
||||
}
|
||||
other, err := New(db, s.consumer+"-other", s.queue)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result, err = other.Apply(ctx, event); err != nil || result.Duplicate {
|
||||
t.Fatal("different consumer purpose shared dedup scope", err)
|
||||
}
|
||||
wrongQueue := testStore(t, db)
|
||||
if _, err = wrongQueue.Apply(ctx, event); !errors.Is(err, ErrInvalidEvent) {
|
||||
t.Fatal("cross-queue event accepted", err)
|
||||
}
|
||||
}
|
||||
func TestIntegrationReceiverTransactionRollback(t *testing.T) {
|
||||
db, _ := integrationDB(t)
|
||||
s := testStore(t, db)
|
||||
ctx, stop := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer stop()
|
||||
event := testEvent(t, s.queue)
|
||||
_, err := db.ExecContext(ctx, `CREATE TRIGGER receiver_test_rollback BEFORE UPDATE ON receiver_task_views FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='injected receiver projection rollback'`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _, _ = db.Exec(`DROP TRIGGER IF EXISTS receiver_test_rollback`) })
|
||||
if _, err = s.Apply(ctx, event); !errors.Is(err, ErrStorage) {
|
||||
t.Fatal("injected update did not fail", err)
|
||||
}
|
||||
var views int
|
||||
if err = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM receiver_task_views WHERE consumer_name=? AND queue_id=?`, s.consumer, s.queue).Scan(&views); err != nil || views != 0 || inboxCount(t, ctx, s) != 0 {
|
||||
t.Fatal("partial transaction persisted", err)
|
||||
}
|
||||
if _, err = db.ExecContext(ctx, `DROP TRIGGER receiver_test_rollback`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result, err := s.Apply(ctx, event); err != nil || !result.Applied || result.Duplicate {
|
||||
t.Fatal("rollback poisoned event identity", err)
|
||||
}
|
||||
}
|
||||
59
examples/result-inbox/internal/inbox/store_test.go
Normal file
59
examples/result-inbox/internal/inbox/store_test.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package inbox
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
orbit "git.yuntuo.hk/k2safe/orbit/sdk/go"
|
||||
)
|
||||
|
||||
func newID(t *testing.T) string {
|
||||
t.Helper()
|
||||
id, err := orbit.NewEventMessageID()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
func testEvent(t *testing.T, queue string) orbit.ResultEvent {
|
||||
t.Helper()
|
||||
task := newID(t)
|
||||
return orbit.ResultEvent{ID: newID(t), Sequence: 7, Type: "task.succeeded", AggregateKind: "task", AggregateID: task, AggregateRevision: 9007199254740993, OccurredAt: time.Now().UTC().Truncate(time.Microsecond), Details: json.RawMessage(`{"origin":"worker"}`), Snapshot: orbit.TaskResultSnapshot{SchemaVersion: 1, TaskID: task, QueueID: queue, State: orbit.TaskSucceeded, Revision: 9007199254740993, Result: json.RawMessage(`{"n":9007199254740993,"text":"<&>"}`)}}
|
||||
}
|
||||
func TestFrozenEventIdentity(t *testing.T) {
|
||||
s := &Store{consumer: "result-view", queue: newID(t)}
|
||||
e := testEvent(t, s.queue)
|
||||
raw, a, err := s.document(e)
|
||||
if err != nil || !bytes.Contains(raw, []byte("9007199254740993")) || !bytes.Contains(raw, []byte("<&>")) {
|
||||
t.Fatal("precision changed", err)
|
||||
}
|
||||
e.Snapshot.Result = json.RawMessage(`{ "text": "<&>", "n": 9007199254740993 }`)
|
||||
_, b, err := s.document(e)
|
||||
if err != nil || a != b {
|
||||
t.Fatal("equivalent formatting changed identity", err)
|
||||
}
|
||||
e.OccurredAt = e.OccurredAt.In(time.FixedZone("offset", 8*3600))
|
||||
_, b, err = s.document(e)
|
||||
if err != nil || a != b {
|
||||
t.Fatal("timezone representation changed event identity", err)
|
||||
}
|
||||
e.Snapshot.Result = json.RawMessage(`{"n":9007199254740994,"text":"<&>"}`)
|
||||
_, b, err = s.document(e)
|
||||
if err != nil || a == b {
|
||||
t.Fatal("changed result not detected", err)
|
||||
}
|
||||
e.Snapshot.QueueID = newID(t)
|
||||
if _, _, err = s.document(e); !errors.Is(err, ErrInvalidEvent) {
|
||||
t.Fatal("wrong receiver scope accepted", err)
|
||||
}
|
||||
}
|
||||
func TestCanonicalRejectsDuplicateAndInvalidJSON(t *testing.T) {
|
||||
for _, raw := range []string{`{"a":1,"a":2}`, `{"a":null,"a":2}`, `{"a":1} {}`, `{"a":`, `{"a":"\ud800"}`, `{"a":"\udfff"}`, "\xff"} {
|
||||
if _, err := canonicalJSON([]byte(raw)); !errors.Is(err, ErrInvalidEvent) {
|
||||
t.Fatal("invalid document accepted")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user