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,192 @@
// sender 演示业务事务Outbox与可重启的有限发送器。
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"os/signal"
"syscall"
"time"
"git.yuntuo.hk/k2safe/orbit/examples/task-outbox/internal/outbox"
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 {
return errors.New("usage: sender init|enqueue <operation_id> <source_ref>|send|status <operation_id>|demo")
}
command := os.Args[1]
if !(command == "init" && len(os.Args) == 2 || command == "send" && len(os.Args) == 2 || command == "demo" && len(os.Args) == 2 || command == "enqueue" && len(os.Args) == 4 || command == "status" && len(os.Args) == 3) {
return errors.New("invalid sender command or arguments")
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
open, cancel := context.WithTimeout(ctx, 10*time.Second)
db, err := outbox.Open(open, os.Getenv("ORBIT_SENDER_DSN"))
cancel()
if err != nil {
return err
}
defer db.Close()
if command == "init" {
bounded, done := context.WithTimeout(ctx, 10*time.Second)
defer done()
if err = outbox.Init(bounded, db); err != nil {
return err
}
fmt.Println("sender_schema_initialized=true foreign_keys=0")
return nil
}
api, ns, queue, err := client()
if err != nil {
return err
}
store, err := outbox.New(db, ns, queue)
if err != nil {
return err
}
switch command {
case "enqueue":
id, source := os.Args[2], os.Args[3]
request := request(queue, id, source)
duplicate, err := store.Enqueue(ctx, id, source, request)
if err != nil {
return err
}
fmt.Printf("operation=%s local_committed=true duplicate=%t\n", id, duplicate)
return nil
case "status":
r, err := store.Status(ctx, os.Args[2])
if err != nil {
return err
}
printRecord(r)
return nil
case "demo":
return demo(ctx, store, api, queue)
case "send":
for ctx.Err() == nil {
r, err := store.SendOnce(ctx, api)
wait := 500 * time.Millisecond
if err == nil {
printRecord(r)
} else if !errors.Is(err, outbox.ErrIdle) {
fmt.Fprintln(os.Stderr, err)
wait = 2 * time.Second
}
timer := time.NewTimer(wait)
select {
case <-ctx.Done():
timer.Stop()
case <-timer.C:
}
}
return nil
}
return nil
}
func client() (*orbit.Client, string, string, error) {
token, ns, queue := os.Getenv("ORBIT_TOKEN"), os.Getenv("ORBIT_NAMESPACE_ID"), 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 sender credential file")
}
var c struct {
Token string `json:"token"`
NamespaceID string `json:"namespace_id"`
QueueID string `json:"queue_id"`
}
if json.Unmarshal(raw, &c) != nil {
return nil, "", "", errors.New("invalid sender credential file")
}
if token == "" {
token = c.Token
}
if ns == "" {
ns = c.NamespaceID
}
if queue == "" {
queue = c.QueueID
}
}
if token == "" {
return nil, "", "", errors.New("sender credential is required")
}
c, err := orbit.NewClient(os.Getenv("ORBIT_URL"), orbit.Options{Token: token})
return c, ns, queue, err
}
func request(queue, id, source string) orbit.SubmitTaskRequest {
payload, _ := json.Marshal(struct {
SourceRef string `json:"source_ref"`
Sequence uint64 `json:"sequence"`
}{source, 9007199254740993})
return orbit.SubmitTaskRequest{QueueID: queue, Handler: "object.prepare", HandlerVersion: "v1", PayloadVersion: "v1", Payload: payload, BusinessKey: id, TaskTimeoutMS: 60000}
}
func printRecord(r outbox.Record) {
task := ""
if r.TaskID != nil {
task = *r.TaskID
}
fmt.Printf("operation=%s state=%s sends=%d code=%s task=%s\n", r.OperationID, r.State, r.SendCount, r.LastCode, task)
}
func demo(parent context.Context, s *outbox.Store, api *orbit.Client, queue string) error {
ctx, stop := context.WithTimeout(parent, 20*time.Second)
defer stop()
id, err := orbit.NewEventMessageID()
if err != nil {
return err
}
// 演示每次新建一个操作;真实接入必须先由业务方保存ID再调用Enqueue。
fmt.Printf("demo_operation=%s\n", id)
in := request(queue, id, "objects/outbox-demo.txt")
if _, err = s.Enqueue(ctx, id, "objects/outbox-demo.txt", in); err != nil {
return err
}
duplicate, err := s.Enqueue(ctx, id, "objects/outbox-demo.txt", in)
if err != nil || !duplicate {
return errors.New("outbox local duplicate was not recovered")
}
for {
r, err := s.Status(ctx, id)
if err != nil {
return err
}
if r.State == "REVIEW" {
return errors.New("outbox demo needs review using its printed operation identity")
}
if r.State == "ACCEPTED" {
task, err := api.GetTask(ctx, *r.TaskID)
if err != nil {
return err
}
if _, err = api.CancelTask(ctx, task.ID, "outbox-demo-cancel-"+id, orbit.CancelTaskRequest{ExpectedRevision: task.Revision, Reason: "outbox submission demo completed"}); err != nil {
return err
}
printRecord(r)
fmt.Println("local_duplicate=true accepted_once=true execution_success_not_implied=true")
return nil
}
_, err = s.SendOnce(ctx, api)
if err != nil && !errors.Is(err, outbox.ErrIdle) {
return err
}
timer := time.NewTimer(100 * time.Millisecond)
select {
case <-ctx.Done():
timer.Stop()
return ctx.Err()
case <-timer.C:
}
}
}