feat: add reusable MySQL outbox and HTTP lifecycle example
This commit is contained in:
179
adapters/outbox/mysql/transaction_integration_test.go
Normal file
179
adapters/outbox/mysql/transaction_integration_test.go
Normal 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) }
|
||||
Reference in New Issue
Block a user