feat: add durable business inbox example and consumer crash recovery

This commit is contained in:
2026-09-17 20:49:51 +08:00
parent b17d9b1977
commit a8a8dcfe1e
27 changed files with 1601 additions and 17 deletions

View 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
}