feat: add durable business task outbox example and fault recovery

This commit is contained in:
2026-09-17 23:13:48 +08:00
parent 2b8213a4f7
commit 522fe2ef3e
27 changed files with 2195 additions and 7 deletions

View File

@@ -0,0 +1,176 @@
package outbox
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"strconv"
"strings"
"sync/atomic"
"testing"
"time"
orbit "git.yuntuo.hk/k2safe/orbit/sdk/go"
)
func realClient(t *testing.T) (*orbit.Client, string, string) {
t.Helper()
base, path := os.Getenv("ORBIT_TEST_SENDER_URL"), os.Getenv("ORBIT_TEST_SENDER_CREDENTIALS_FILE")
if base == "" || path == "" {
t.Skip("requires isolated Orbit process")
}
if !strings.HasPrefix(base, "http://127.0.0.1:") {
t.Fatal("sender HTTP test requires loopback")
}
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal("cannot read sender credential")
}
var cred struct {
Token string `json:"token"`
}
if json.Unmarshal(raw, &cred) != nil || cred.Token == "" {
t.Fatal("invalid sender credential")
}
api, err := orbit.NewClient(base, orbit.Options{Token: cred.Token})
if err != nil {
t.Fatal(err)
}
return api, base, cred.Token
}
func realStore(t *testing.T) (*Store, *orbit.Client, string, string) {
t.Helper()
db, _ := integrationDB(t)
api, base, token := realClient(t)
ctx, stop := context.WithTimeout(context.Background(), 10*time.Second)
defer stop()
id := testID(t)
queue, err := api.CreateQueue(ctx, "outbox-test-"+id, orbit.CreateQueueRequest{Name: "outbox-" + id, MaxConcurrency: 1, RatePerSecond: 10})
if err != nil {
t.Fatal(err)
}
s, err := New(db, queue.Queue.NamespaceID, queue.Queue.ID)
if err != nil {
t.Fatal(err)
}
return s, api, base, token
}
func cancelAccepted(t *testing.T, api *orbit.Client, id string) {
t.Helper()
ctx, stop := context.WithTimeout(context.Background(), 5*time.Second)
defer stop()
task, err := api.GetTask(ctx, id)
if err != nil {
t.Fatal(err)
}
if _, err = api.CancelTask(ctx, id, "outbox-test-cancel-"+id, orbit.CancelTaskRequest{ExpectedRevision: task.Revision, Reason: "outbox regression completed"}); err != nil {
t.Fatal(err)
}
}
func TestIntegrationSenderHTTPReceiptLoss(t *testing.T) {
s, api, base, token := realStore(t)
id := enqueue(t, s)
target, _ := url.Parse(base)
var drop atomic.Bool
drop.Store(true)
var posts atomic.Int32
committed := make(chan orbit.SubmitTaskResult, 1)
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
up := r.Clone(r.Context())
up.URL.Scheme = target.Scheme
up.URL.Host = target.Host
up.Host = target.Host
up.RequestURI = ""
resp, err := http.DefaultTransport.RoundTrip(up)
if err != nil {
http.Error(w, "upstream unavailable", 502)
return
}
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, (1<<20)+1))
if err != nil {
http.Error(w, "upstream read failed", 502)
return
}
if r.Method == "POST" && r.URL.Path == "/v1/tasks" {
posts.Add(1)
if r.Header.Get("Idempotency-Key") != "outbox-task-"+id {
t.Error("original key changed")
}
if resp.StatusCode >= 200 && resp.StatusCode < 300 && drop.CompareAndSwap(true, false) {
var envelope struct {
Data orbit.SubmitTaskResult `json:"data"`
}
if json.Unmarshal(raw, &envelope) != nil || !idPattern.MatchString(envelope.Data.Task.ID) {
t.Error("invalid real receipt")
http.Error(w, "bad receipt", 502)
return
}
committed <- envelope.Data
// 先发送成功响应头与不完整正文,避免Transport把无响应头的断线当成可透明重放。
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Content-Length", strconv.Itoa(len(raw)))
w.WriteHeader(resp.StatusCode)
w.Write(raw[:1])
w.(http.Flusher).Flush()
conn, _, err := w.(http.Hijacker).Hijack()
if err == nil {
conn.Close()
}
return
}
}
for key, values := range resp.Header {
w.Header()[key] = values
}
w.WriteHeader(resp.StatusCode)
w.Write(raw)
}))
defer proxy.Close()
through, err := orbit.NewClient(proxy.URL, orbit.Options{Token: token})
if err != nil {
t.Fatal(err)
}
ctx, stop := context.WithTimeout(context.Background(), 20*time.Second)
defer stop()
r, err := s.SendOnce(ctx, through)
if err != nil || r.State != "PENDING" || r.LastCode != "UNCONFIRMED" {
t.Fatal("unknown remote commit classified as done", r.State, err)
}
var original orbit.SubmitTaskResult
select {
case original = <-committed:
case <-ctx.Done():
t.Fatal("real accepted receipt not intercepted")
}
if _, err = api.GetTask(ctx, original.Task.ID); err != nil {
t.Fatal("remote was not actually committed", err)
}
execSQL(t, s.db, `UPDATE sender_outbox SET wake_at=UTC_TIMESTAMP(6) WHERE namespace_id=? AND operation_id=?`, s.namespace, id)
r, err = s.SendOnce(ctx, through)
if err != nil || r.State != "ACCEPTED" || r.TaskID == nil || *r.TaskID != original.Task.ID || r.SendCount != 2 {
t.Fatal("original identity not recovered", r.State, err)
}
var receipt orbit.SubmitTaskResult
if json.Unmarshal(r.ReceiptJSON, &receipt) != nil || !receipt.Deduplicated || !strings.Contains(string(receipt.Task.Payload), "9007199254740993") {
t.Fatal("replayed receipt or precision lost")
}
page, err := api.ListTasks(ctx, orbit.ListTasksRequest{QueueID: s.queue})
if err != nil || len(page.Items) != 1 || posts.Load() != 2 {
t.Fatal("remote duplicate task", len(page.Items), err)
}
// 相同凭据搭配错误的持久化目标命名空间,必须在提交前停止。
wrong, _ := New(s.db, testID(t), s.queue)
wrongID := enqueue(t, wrong)
r, err = wrong.SendOnce(ctx, through)
if err != nil || r.OperationID != wrongID || r.State != "REVIEW" || r.LastCode != "TARGET_MISMATCH" || posts.Load() != 2 {
t.Fatal("wrong namespace submitted", r.State, err)
}
cancelAccepted(t, api, original.Task.ID)
t.Log("real HTTP receipt discarded after Orbit commit; original key replay recovered exactly one task; wrong namespace never posted")
}