feat: add reusable MySQL outbox and HTTP lifecycle example

This commit is contained in:
2026-09-24 20:54:36 +08:00
parent 0140c78ea7
commit e29dec717f
52 changed files with 2488 additions and 357 deletions

View File

@@ -0,0 +1,179 @@
package outbox
import (
"context"
"database/sql"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
orbit "git.yuntuo.hk/k2safe/orbit/sdk/go"
)
func TestIntegrationCallerOwnedTransaction(t *testing.T) {
db, _ := integrationDB(t)
s, err := New(db, testID(t), testID(t), Options{})
if err != nil {
t.Fatal(err)
}
ctx := context.Background()
id := testID(t)
tx, err := db.BeginTx(ctx, nil)
if err != nil {
t.Fatal(err)
}
defer tx.Rollback()
dup, err := s.EnqueueTx(ctx, tx, id, testRequest(s.queue))
if err != nil || dup {
t.Fatal(dup, err)
}
if dup, err = s.EnqueueTx(ctx, tx, id, testRequest(s.queue)); err != nil || !dup {
t.Fatal("same transaction duplicate not recognized", dup, err)
}
// 从另一连接看不到未提交的待发任务;包不得偷偷提交,也不得发起HTTP。
if _, err = s.Status(ctx, id); !errors.Is(err, ErrNotFound) {
t.Fatal("adapter committed caller transaction", err)
}
if _, err = s.claim(ctx); !errors.Is(err, ErrIdle) {
t.Fatal("uncommitted task was claimable", err)
}
if err = tx.Rollback(); err != nil {
t.Fatal(err)
}
if _, err = s.Status(ctx, id); !errors.Is(err, ErrNotFound) {
t.Fatal("caller rollback lost", err)
}
// 先在RR事务建立旧快照,再由另一事务提交相同身份,重复核对必须是当前读。
tx, err = db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelRepeatableRead})
if err != nil {
t.Fatal(err)
}
defer tx.Rollback()
var n int
if err = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM sender_outbox WHERE namespace_id=?`, s.namespace).Scan(&n); err != nil {
t.Fatal(err)
}
other, err := db.BeginTx(ctx, nil)
if err != nil {
t.Fatal(err)
}
defer other.Rollback()
if _, err = s.EnqueueTx(ctx, other, id, testRequest(s.queue)); err != nil {
t.Fatal(err)
}
if err = other.Commit(); err != nil {
t.Fatal(err)
}
if dup, err = s.EnqueueTx(ctx, tx, id, testRequest(s.queue)); err != nil || !dup {
t.Fatal("RR current read failed", dup, err)
}
if err = tx.Commit(); err != nil {
t.Fatal(err)
}
claim, err := s.claim(ctx)
if err != nil {
t.Fatal(err)
}
receipt := fakeReceipt(t, s)
if err = s.finish(ctx, claim, "ACCEPTED", "ACCEPTED", 0, &receipt); err != nil {
t.Fatal("optional hook requires business schema", err)
}
if status(t, s, id).State != "ACCEPTED" {
t.Fatal("missing acceptance")
}
}
func TestIntegrationSenderRuntimeDrain(t *testing.T) {
for _, timeout := range []bool{false, true} {
t.Run(map[bool]string{false: "finish_inflight", true: "bounded_timeout"}[timeout], func(t *testing.T) {
db, _ := integrationDB(t)
s, err := New(db, testID(t), testID(t), Options{})
if err != nil {
t.Fatal(err)
}
id := testID(t)
tx, err := db.Begin()
if err != nil {
t.Fatal(err)
}
defer tx.Rollback()
if _, err = s.EnqueueTx(context.Background(), tx, id, testRequest(s.queue)); err != nil {
t.Fatal(err)
}
if err = tx.Commit(); err != nil {
t.Fatal(err)
}
entered := make(chan struct{})
release := make(chan struct{})
unblock := sync.OnceFunc(func() { close(release) })
receipt := fakeReceipt(t, s)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.Method == "GET" {
writeTestJSON(w, map[string]any{"request_id": id, "data": orbit.Queue{ID: s.queue, NamespaceID: s.namespace}})
return
}
close(entered)
select {
case <-release:
case <-r.Context().Done():
return
}
w.WriteHeader(201)
writeTestJSON(w, map[string]any{"request_id": id, "data": receipt})
}))
defer server.Close()
defer unblock()
api, err := orbit.NewClient(server.URL, orbit.Options{Token: "test"})
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
grace := time.Second
if timeout {
grace = 20 * time.Millisecond
}
done := make(chan error, 1)
go func() { done <- s.Run(ctx, api, RunOptions{ShutdownTimeout: grace}) }()
select {
case <-entered:
case <-time.After(5 * time.Second):
t.Fatal("sender did not enter HTTP")
}
cancel()
if !timeout {
select {
case err := <-done:
t.Fatal("did not drain", err)
case <-time.After(25 * time.Millisecond):
}
unblock()
}
select {
case err = <-done:
case <-time.After(5 * time.Second):
t.Fatal("shutdown hung")
}
if timeout {
if !errors.Is(err, ErrShutdownTimeout) {
t.Fatal(err)
}
if status(t, s, id).State == "ACCEPTED" {
t.Fatal("timeout fabricated acceptance")
}
} else {
if err != nil || status(t, s, id).State != "ACCEPTED" {
t.Fatal(err)
}
}
})
}
}
func writeTestJSON(w http.ResponseWriter, v any) { _ = json.NewEncoder(w).Encode(v) }