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,11 @@
# 在现有 HTTP 服务中使用 Orbit
可运行的 Go 服务:HTTP 业务事务 → 公共 MySQL Outbox → Orbit → 内嵌 Worker → WebSocket 结果 → 业务 Inbox。
- [完整准备、运行、API、故障与退出说明](../../docs/examples/http-service.md)
- [业务路由与组件装配](cmd/server/main.go)
- [生命周期顺序](cmd/server/lifecycle.go)
- [业务事务](internal/jobs/store.go)
- [业务 Inbox 示例实现](internal/inbox/store.go),沿用独立 result-inbox 示例的事件去重与版本约束
`init` 创建业务库表;`provision` 用稳定身份创建结果订阅;`serve` 使用配置中的订阅 ID,启动时无需 Orbit 在线。默认只监听回环地址 127.0.0.1:8090。接入已有服务时挂载业务路由,并接入原有鉴权、监控及生命周期。

View File

@@ -0,0 +1,251 @@
package main
import (
"bufio"
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/http/httputil"
"net/url"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync/atomic"
"syscall"
"testing"
"time"
outbox "git.yuntuo.hk/k2safe/orbit/adapters/outbox/mysql"
orbit "git.yuntuo.hk/k2safe/orbit/sdk/go"
"github.com/go-sql-driver/mysql"
)
func TestIntegrationHTTPServiceRestartAndResults(t *testing.T) {
dsn, base, path := os.Getenv("ORBIT_TEST_HTTP_DSN"), os.Getenv("ORBIT_TEST_HTTP_URL"), os.Getenv("ORBIT_TEST_HTTP_CREDENTIALS_FILE")
if dsn == "" || base == "" || path == "" {
t.Skip("requires disposable business database and Orbit process")
}
cfg, err := mysql.ParseDSN(dsn)
if err != nil || cfg.DBName != "orbit_http_test" || !strings.HasPrefix(cfg.Addr, "127.0.0.1:") || !strings.HasPrefix(base, "http://127.0.0.1:") {
t.Fatal("requires isolated loopback environment")
}
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Second)
defer cancel()
db, err := outbox.Open(ctx, dsn)
if err != nil {
t.Fatal(err)
}
defer db.Close()
binary := filepath.Join(t.TempDir(), "server")
build := exec.CommandContext(ctx, filepath.Join(runtime.GOROOT(), "bin", "go"), "build", "-race", "-o", binary, ".")
if raw, err := build.CombinedOutput(); err != nil {
t.Fatalf("build failed %v %s", err, raw)
}
env := []string{}
for _, v := range os.Environ() {
if !strings.HasPrefix(v, "ORBIT_") && !strings.HasPrefix(v, "APP_") {
env = append(env, v)
}
}
env = append(env, "APP_MYSQL_DSN="+dsn, "APP_HTTP_ADDR=127.0.0.1:0", "ORBIT_URL="+base, "ORBIT_CREDENTIALS_FILE="+path)
call := func(args ...string) string {
t.Helper()
cmd := exec.CommandContext(ctx, binary, args...)
cmd.Env = env
raw, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("command failed: %v %s", err, raw)
}
return strings.TrimSpace(string(raw))
}
call("init")
sub := call("provision")
if call("provision") != sub {
t.Fatal("provision changed subscription")
}
env = append(env, "ORBIT_SUBSCRIPTION_ID="+sub)
target, _ := url.Parse(base)
proxy := httputil.NewSingleHostReverseProxy(target)
var offline atomic.Bool
offline.Store(true)
gateway := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if offline.Load() {
http.Error(w, "offline", 503)
return
}
proxy.ServeHTTP(w, r)
}))
defer gateway.Close()
for i, v := range env {
if strings.HasPrefix(v, "ORBIT_URL=") {
env[i] = "ORBIT_URL=" + gateway.URL
}
}
type running struct {
cmd *exec.Cmd
done chan error
url string
stderr *bytes.Buffer
}
start := func() *running {
t.Helper()
cmd := exec.CommandContext(ctx, binary, "serve")
cmd.Env = env
stdout, err := cmd.StdoutPipe()
if err != nil {
t.Fatal(err)
}
stderr := new(bytes.Buffer)
cmd.Stderr = stderr
if err = cmd.Start(); err != nil {
t.Fatal(err)
}
address := make(chan string, 1)
go func() {
scanner := bufio.NewScanner(stdout)
for scanner.Scan() {
if value, ok := strings.CutPrefix(scanner.Text(), "http_listening="); ok {
address <- "http://" + value
}
}
close(address)
}()
done := make(chan error, 1)
go func() { done <- cmd.Wait(); close(done) }()
run := &running{cmd: cmd, done: done, stderr: stderr}
t.Cleanup(func() { cmd.Process.Kill(); <-done })
select {
case run.url = <-address:
if run.url == "" {
<-done
t.Fatalf("startup failed: %s", stderr.String())
}
case <-ctx.Done():
t.Fatal("startup timeout")
}
return run
}
httpClient := &http.Client{Timeout: 5 * time.Second}
get := func(base, path string) map[string]any {
t.Helper()
r, err := httpClient.Get(base + path)
if err != nil {
t.Fatal(err)
}
defer r.Body.Close()
if r.StatusCode != 200 {
t.Fatalf("GET %s: %d", path, r.StatusCode)
}
var v map[string]any
if json.NewDecoder(r.Body).Decode(&v) != nil {
t.Fatal("invalid JSON")
}
return v
}
id, err := orbit.NewEventMessageID()
if err != nil {
t.Fatal(err)
}
payload := []byte(`{"operation_id":"` + id + `","source_ref":"objects/http-demo.txt"}`)
post := func(base string, body []byte, status int) map[string]any {
t.Helper()
r, err := httpClient.Post(base+"/jobs", "application/json", bytes.NewReader(body))
if err != nil {
t.Fatal(err)
}
defer r.Body.Close()
if r.StatusCode != status {
raw, _ := io.ReadAll(r.Body)
t.Fatalf("POST: %d %s", r.StatusCode, raw)
}
var out map[string]any
if status == 202 {
json.NewDecoder(r.Body).Decode(&out)
}
return out
}
wait := func(check func() bool) {
t.Helper()
for !check() {
select {
case <-ctx.Done():
t.Fatal("condition timeout")
case <-time.After(50 * time.Millisecond):
}
}
}
first := start()
wait(func() bool {
r, err := httpClient.Get(first.url + "/health/ready")
if err != nil {
return false
}
r.Body.Close()
return r.StatusCode == 200
})
if v := post(first.url, payload, 202); v["local_committed"] != true || v["duplicate"] != false {
t.Fatal(v)
}
wait(func() bool {
var n int
err := db.QueryRowContext(ctx, `SELECT send_count FROM sender_outbox WHERE operation_id=?`, id).Scan(&n)
return err == nil && n > 0
})
first.cmd.Process.Kill()
if err := <-first.done; err == nil {
t.Fatal("expected real SIGKILL")
}
offline.Store(false)
second := start()
wait(func() bool { return get(second.url, "/jobs/"+id)["execution"] == "SUCCEEDED" })
if post(second.url, payload, 202)["duplicate"] != true {
t.Fatal("original operation duplicated after restart")
}
post(second.url, bytes.ReplaceAll(payload, []byte("http-demo"), []byte("changed")), 409)
view := get(second.url, "/jobs/"+id)
result, ok := view["result"].(map[string]any)
if !ok || result["normalized_ref"] != "OBJECTS/HTTP-DEMO.TXT" {
t.Fatal(view)
}
var jobs, inboxRows, fks int
if err = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM sender_jobs WHERE operation_id=?`, id).Scan(&jobs); err != nil {
t.Fatal(err)
}
if err = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM receiver_inbox WHERE queue_id=(SELECT queue_id FROM sender_outbox WHERE operation_id=?)`, id).Scan(&inboxRows); err != nil {
t.Fatal(err)
}
if err = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM information_schema.REFERENTIAL_CONSTRAINTS WHERE CONSTRAINT_SCHEMA=DATABASE()`).Scan(&fks); err != nil || jobs != 1 || inboxRows != 1 || fks != 0 {
t.Fatal(jobs, inboxRows, fks, err)
}
// 先看到平台持久ACK,再发SIGTERM,验证通知不是只停留在业务内存或数据库。
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
var creds config
if json.Unmarshal(raw, &creds) != nil {
t.Fatal("credentials")
}
api, err := orbit.NewClient(base, orbit.Options{Token: creds.Token})
if err != nil {
t.Fatal(err)
}
wait(func() bool { v, err := api.GetSubscription(ctx, sub); return err == nil && v.PendingCount == 0 })
if err = second.cmd.Process.Signal(syscall.SIGTERM); err != nil {
t.Fatal(err)
}
select {
case err = <-second.done:
if err != nil {
t.Fatalf("SIGTERM failed %v %s", err, second.stderr.String())
}
case <-ctx.Done():
t.Fatal("shutdown timeout")
}
t.Log("Orbit unavailable: HTTP transaction committed; real SIGKILL/restart recovered original operation; Worker + persistent Inbox + ACK completed once; conflict rejected; SIGTERM exited cleanly; zero foreign keys")
}

View File

@@ -0,0 +1,99 @@
package main
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"sync/atomic"
"time"
)
// component 不接管进程信号;宿主负责为每一类生命周期提供独立取消和等待。
type component struct {
name string
run func(context.Context) error
}
type completion struct {
name string
err error
}
// serve 在HTTP监听已成功后启动后台组件;返回前完成HTTP→发送/执行→结果消费的关闭顺序。
func serve(parent context.Context, listener net.Listener, server *http.Server, ready *atomic.Bool, workers []component, consumer component, grace time.Duration) error {
workCtx, stopWork := context.WithCancel(context.Background())
defer stopWork()
eventCtx, stopEvents := context.WithCancel(context.Background())
defer stopEvents()
workDone := make(chan completion, len(workers))
eventDone := make(chan completion, 1)
for _, c := range workers {
go func() { workDone <- completion{c.name, c.run(workCtx)} }()
}
go func() { eventDone <- completion{consumer.name, consumer.run(eventCtx)} }()
httpDone := make(chan error, 1)
ready.Store(true)
go func() { httpDone <- server.Serve(listener) }()
remaining := len(workers)
eventsPending := true
var result error
add := func(c completion) {
if c.err != nil {
result = errors.Join(result, fmt.Errorf("%s: %w", c.name, c.err))
}
}
select {
case <-parent.Done():
case err := <-httpDone:
if !errors.Is(err, http.ErrServerClosed) {
result = errors.Join(result, err)
}
case c := <-workDone:
remaining--
if c.err == nil {
c.err = errors.New("unexpected component exit")
}
add(c)
case c := <-eventDone:
eventsPending = false
if c.err == nil {
c.err = errors.New("unexpected component exit")
}
add(c)
}
ready.Store(false)
// 用新的关闭上下文;进程信号的context此时可能已取消,不能用它调用Shutdown。
shut, cancel := context.WithTimeout(context.Background(), grace)
if err := server.Shutdown(shut); err != nil {
result = errors.Join(result, fmt.Errorf("http shutdown: %w", err))
_ = server.Close()
}
cancel()
stopWork()
deadline := time.NewTimer(grace)
defer deadline.Stop()
for remaining > 0 {
select {
case c := <-workDone:
remaining--
add(c)
case <-deadline.C:
result = errors.Join(result, errors.New("background shutdown deadline exceeded"))
remaining = 0
}
}
// 结果消费器最后停止。仍未投递的结果会在重启后由持久订阅恢复,不以本地空闲判断全局完成。
stopEvents()
if eventsPending {
timer := time.NewTimer(grace)
defer timer.Stop()
select {
case c := <-eventDone:
add(c)
case <-timer.C:
result = errors.Join(result, errors.New("consumer shutdown deadline exceeded"))
}
}
return result
}

View File

@@ -0,0 +1,90 @@
package main
import (
"context"
"errors"
"net"
"net/http"
"sync/atomic"
"testing"
"time"
)
func TestShutdownKeepsDependenciesUntilHTTPReturns(t *testing.T) {
entered := make(chan struct{})
release := make(chan struct{})
workerStopped := make(chan struct{})
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer listener.Close()
server := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { close(entered); <-release; w.WriteHeader(200) })}
ready := new(atomic.Bool)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
done := make(chan error, 1)
go func() {
done <- serve(ctx, listener, server, ready, []component{{"worker", func(ctx context.Context) error { <-ctx.Done(); close(workerStopped); return nil }}}, component{"events", func(ctx context.Context) error {
<-ctx.Done()
select {
case <-workerStopped:
return nil
default:
return errors.New("consumer stopped before worker")
}
}}, time.Second)
}()
response := make(chan error, 1)
go func() {
r, e := http.Get("http://" + listener.Addr().String())
if e == nil {
r.Body.Close()
}
response <- e
}()
select {
case <-entered:
case <-time.After(time.Second):
t.Fatal("HTTP not entered")
}
cancel()
select {
case <-workerStopped:
t.Fatal("worker stopped during HTTP transaction")
case <-time.After(20 * time.Millisecond):
}
if ready.Load() {
t.Fatal("readiness remained true during shutdown")
}
close(release)
if err = <-response; err != nil {
t.Fatal(err)
}
select {
case err = <-done:
if err != nil {
t.Fatal(err)
}
case <-time.After(3 * time.Second):
t.Fatal("shutdown blocked")
}
}
func TestBackgroundFailureStopsServiceAndPeers(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer listener.Close()
boom := errors.New("fatal worker")
stopped := make(chan struct{})
result := serve(context.Background(), listener, &http.Server{}, new(atomic.Bool), []component{{"worker", func(context.Context) error { return boom }}}, component{"consumer", func(ctx context.Context) error { <-ctx.Done(); close(stopped); return nil }}, time.Second)
if !errors.Is(result, boom) {
t.Fatal(result)
}
select {
case <-stopped:
default:
t.Fatal("consumer leaked")
}
}

View File

@@ -0,0 +1,234 @@
// server 演示将 Orbit SDK 接入现有 HTTP 服务;平台仍独立部署。
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"os"
"os/signal"
"strings"
"sync/atomic"
"syscall"
"time"
outbox "git.yuntuo.hk/k2safe/orbit/adapters/outbox/mysql"
"git.yuntuo.hk/k2safe/orbit/examples/http-service/internal/inbox"
"git.yuntuo.hk/k2safe/orbit/examples/http-service/internal/jobs"
orbit "git.yuntuo.hk/k2safe/orbit/sdk/go"
)
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
type config struct {
Token string `json:"token"`
Namespace string `json:"namespace_id"`
Queue string `json:"queue_id"`
}
func client() (*orbit.Client, config, error) {
c := config{Token: os.Getenv("ORBIT_TOKEN"), Namespace: os.Getenv("ORBIT_NAMESPACE_ID"), Queue: os.Getenv("ORBIT_QUEUE_ID")}
if file := os.Getenv("ORBIT_CREDENTIALS_FILE"); file != "" {
raw, err := os.ReadFile(file)
if err != nil {
return nil, c, errors.New("cannot read credential file")
}
var saved config
if json.Unmarshal(raw, &saved) != nil {
return nil, c, errors.New("invalid credential file")
}
if c.Token == "" {
c.Token = saved.Token
}
if c.Namespace == "" {
c.Namespace = saved.Namespace
}
if c.Queue == "" {
c.Queue = saved.Queue
}
}
if c.Token == "" || c.Namespace == "" || c.Queue == "" {
return nil, c, errors.New("Orbit token, namespace and queue are required")
}
api, err := orbit.NewClient(os.Getenv("ORBIT_URL"), orbit.Options{Token: c.Token})
return api, c, err
}
func run() error {
if len(os.Args) != 2 {
return errors.New("usage: server init|provision|serve")
}
command := os.Args[1]
if command != "init" && command != "provision" && command != "serve" {
return errors.New("unknown command")
}
process, signalStop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer signalStop()
startup, stop := context.WithTimeout(process, 10*time.Second)
defer stop()
if command == "provision" {
api, c, err := client()
if err != nil {
return err
}
// 首次使用专属队列前创建,键和内容固定;部署重试不可改名/换键。运行期间从配置复用ID。
result, err := api.CreateSubscription(startup, "http-service-sub-"+c.Queue, orbit.CreateSubscriptionRequest{Name: "http-service-" + c.Queue, QueueID: c.Queue, MaxPending: 100, MaxAttempts: 20})
if err != nil {
return err
}
fmt.Println(result.Subscription.ID)
return nil
}
db, err := outbox.Open(startup, os.Getenv("APP_MYSQL_DSN"))
if err != nil {
return err
}
// 所有组件完成关闭后才关闭共享连接池;初始化/构造/监听失败也走同一回收路径。
defer db.Close()
if command == "init" {
if err = jobs.Init(startup, db); err != nil {
return err
}
if err = inbox.Init(startup, db); err != nil {
return err
}
fmt.Println("business_schema_initialized=true")
return nil
}
api, c, err := client()
if err != nil {
return err
}
store, err := jobs.New(db, c.Namespace, c.Queue)
if err != nil {
return err
}
// 尽早核验业务迁移;不会自动建表,也不依赖Orbit在线才能启动。
for _, query := range []string{
`SELECT namespace_id,queue_id,operation_id,state FROM sender_outbox LIMIT 0`,
`SELECT namespace_id,operation_id,source_ref,task_id FROM sender_jobs LIMIT 0`,
`SELECT consumer_name,event_id,event_digest FROM receiver_inbox LIMIT 0`,
`SELECT consumer_name,queue_id,task_id,last_revision,result_json FROM receiver_task_views LIMIT 0`,
} {
if _, err = db.ExecContext(startup, query); err != nil {
return errors.New("business schema missing")
}
}
receiver, err := inbox.New(db, "http-service", c.Queue)
if err != nil {
return err
}
worker, err := orbit.NewWorker(api, orbit.WorkerOptions{QueueID: c.Queue, Capacity: 4, ShutdownTimeout: 20 * time.Second, Handlers: []orbit.WorkerHandler{{Capability: orbit.WorkerCapability{Handler: "http.object.prepare", HandlerVersion: "v1", PayloadVersion: "v1"}, Handle: prepare}}})
if err != nil {
return err
}
consumer, err := orbit.NewEventConsumer(api, orbit.EventConsumerOptions{SubscriptionID: os.Getenv("ORBIT_SUBSCRIPTION_ID"), Capacity: 4, ShutdownTimeout: 10 * time.Second, Handle: receiver.Handle})
if err != nil {
return err
}
ready := new(atomic.Bool)
mux := http.NewServeMux()
mux.HandleFunc("GET /health/live", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("live\n")) })
mux.HandleFunc("GET /health/ready", func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 500*time.Millisecond)
defer cancel()
if !ready.Load() || db.PingContext(ctx) != nil {
http.Error(w, "not ready", 503)
return
}
w.Write([]byte("ready\n"))
})
mux.HandleFunc("POST /jobs", func(w http.ResponseWriter, r *http.Request) {
if !ready.Load() {
http.Error(w, "stopping", 503)
return
}
r.Body = http.MaxBytesReader(w, r.Body, 4096)
var in struct {
OperationID string `json:"operation_id"`
SourceRef string `json:"source_ref"`
}
d := json.NewDecoder(r.Body)
d.DisallowUnknownFields()
if d.Decode(&in) != nil || d.Decode(new(any)) != io.EOF {
http.Error(w, "invalid request", 400)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
payload, _ := json.Marshal(map[string]string{"source_ref": in.SourceRef})
duplicate, err := store.Enqueue(ctx, in.OperationID, in.SourceRef, orbit.SubmitTaskRequest{QueueID: c.Queue, Handler: "http.object.prepare", HandlerVersion: "v1", PayloadVersion: "v1", Payload: payload, BusinessKey: in.OperationID, TaskTimeoutMS: 86400000, RecoverySafety: orbit.RecoveryIdempotent, Retry: orbit.RetryPolicy{MaxAttempts: 3, AttemptTimeoutMS: 10000}})
if err != nil {
httpFailure(w, err)
return
}
writeJSON(w, 202, map[string]any{"operation_id": in.OperationID, "local_committed": true, "duplicate": duplicate})
})
mux.HandleFunc("GET /jobs/{id}", func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
result, err := store.View(ctx, r.PathValue("id"))
if err != nil {
httpFailure(w, err)
return
}
writeJSON(w, 200, result)
})
addr := os.Getenv("APP_HTTP_ADDR")
if addr == "" {
addr = "127.0.0.1:8090"
}
host, _, err := net.SplitHostPort(addr)
if err != nil || net.ParseIP(host) == nil || !net.ParseIP(host).IsLoopback() {
return errors.New("example requires a loopback APP_HTTP_ADDR; mount handlers behind your application authentication for production")
}
listener, err := net.Listen("tcp", addr)
if err != nil {
return errors.New("cannot listen on APP_HTTP_ADDR")
}
defer listener.Close()
server := &http.Server{Handler: mux, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, IdleTimeout: 60 * time.Second, MaxHeaderBytes: 16 << 10}
fmt.Printf("http_listening=%s\n", listener.Addr())
return serve(process, listener, server, ready, []component{{"outbox", func(ctx context.Context) error {
return store.Run(ctx, api, outbox.RunOptions{ShutdownTimeout: 20 * time.Second})
}}, {"worker", worker.Run}}, component{"events", consumer.Run}, 25*time.Second)
}
func httpFailure(w http.ResponseWriter, err error) {
code, text := 503, "unconfirmed; retry original operation and content"
if errors.Is(err, outbox.ErrInvalid) {
code, text = 400, "invalid request"
}
if errors.Is(err, outbox.ErrConflict) {
code, text = 409, "operation content conflict"
}
if errors.Is(err, outbox.ErrNotFound) {
code, text = 404, "operation not found"
}
http.Error(w, text, code)
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func prepare(ctx context.Context, e orbit.WorkerExecution) (json.RawMessage, error) {
var in struct {
SourceRef string `json:"source_ref"`
}
if json.Unmarshal(e.Task.Payload, &in) != nil || in.SourceRef == "" {
return nil, &orbit.WorkerFailure{Code: "INVALID_SOURCE"}
}
if err := ctx.Err(); err != nil {
return nil, err
}
// 纯计算可重复;实际外部副作用必须由业务实现幂等或核对,不能照搬幂等声明。
return json.Marshal(map[string]string{"source_ref": in.SourceRef, "normalized_ref": strings.ToUpper(in.SourceRef)})
}

View File

@@ -0,0 +1,16 @@
module git.yuntuo.hk/k2safe/orbit/examples/http-service
go 1.27.1
require (
git.yuntuo.hk/k2safe/orbit/adapters/outbox/mysql v0.0.0
git.yuntuo.hk/k2safe/orbit/sdk/go v0.1.0
github.com/go-sql-driver/mysql v1.10.1
)
require (
filippo.io/edwards25519 v1.2.0 // indirect
github.com/coder/websocket v1.8.15 // indirect
)
replace git.yuntuo.hk/k2safe/orbit/adapters/outbox/mysql => ../../adapters/outbox/mysql

View File

@@ -0,0 +1,8 @@
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
git.yuntuo.hk/k2safe/orbit/sdk/go v0.1.0 h1:OJp+U8dDNKjiXy4ZhO4MMq68Ek8TkjphUAqsjOOg77c=
git.yuntuo.hk/k2safe/orbit/sdk/go v0.1.0/go.mod h1:9KlMW7Gy+EGaDoEwr3wHHFY47mnAymSfYeQpMS7h4ag=
github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/go-sql-driver/mysql v1.10.1 h1:arlSnNLq6a5yxGxV7qg9lF4j0C+KwD6NbQyKr9QL6ME=
github.com/go-sql-driver/mysql v1.10.1/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk=

View File

@@ -0,0 +1,28 @@
package inbox
import "time"
// Record 对应receiver_inbox,与业务视图同事务提交;存在记录才表示该事件处理已提交。
type Record struct {
ConsumerName string // ConsumerName 固定用途名;进程重启及多实例必须复用。
QueueID string // QueueID 固定来源队列,不能从连接会话推导。
EventID string // EventID 稳定事件身份,不使用交付ID或代次。
EventDigest []byte // EventDigest 32字节SHA256,防止同事件身份被换成不同内容。
EventJSON []byte // EventJSON 规范化冻结事件,含业务数据、不含租约秘密。
Applied bool // Applied 该事件是否推进业务视图;旧版本为false但仍可靠保存。
ReceivedAt time.Time // ReceivedAt 数据库UTC受理时间,微秒精度。
}
// TaskView 对应receiver_task_views;这是接收方业务数据,不是Orbit任务权威状态。
type TaskView struct {
ConsumerName string // ConsumerName 与Inbox相同的固定用途。
QueueID string // QueueID 与Inbox相同的绑定队列。
TaskID string // TaskID 原任务标识,业务视图主键的一部分。
LastRevision uint64 // LastRevision 已应用的最大任务版本;0只允许在未提交事务的占位行中。
LastEventID *string // LastEventID 最后推进视图的事件;仅占位状态可为空。
LastState string // LastState 冻结结果状态;NEEDS_REVIEW不能作为成功或最终失败。
ResultJSON []byte // ResultJSON 当前结果,nil表示来源未提供;数值保持精确。
AppliedEvents uint64 // AppliedEvents 实际推进次数,重复及迟到旧版本不增加。
CreatedAt time.Time // CreatedAt 视图首次创建的UTC时间,微秒精度。
UpdatedAt time.Time // UpdatedAt 最后推进的UTC时间,微秒精度。
}

View File

@@ -0,0 +1,32 @@
-- 接收方独立数据库的初始示例表;不属于Orbit平台迁移,不创建外键或级联删除。
CREATE TABLE receiver_inbox (
consumer_name VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL COMMENT '固定消费者用途;同一用途重启或多实例必须复用',
queue_id CHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL COMMENT '接收方绑定的队列标识;隔离不同来源范围',
event_id CHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL COMMENT '原事件标识;跨连接及交付代次保持不变',
event_digest BINARY(32) NOT NULL COMMENT '规范化冻结事件JSON的SHA256;同身份不同内容拒绝确认',
event_json MEDIUMTEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL COMMENT '冻结事件及业务结果;包含业务数据须限制访问,不含租约证明',
applied BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否推进业务视图;迟到的旧版本只保留Inbox历史',
received_at DATETIME(6) NOT NULL COMMENT '本次业务事务受理事件的UTC时间,微秒精度',
PRIMARY KEY (consumer_name,queue_id,event_id),
CONSTRAINT ck_receiver_event_json CHECK (JSON_VALID(event_json)),
CONSTRAINT ck_receiver_applied CHECK (applied IN (0,1))
) ENGINE=InnoDB COMMENT='事务Inbox;去重记录与业务视图一起提交,禁止独立预写成功标志';
CREATE TABLE receiver_task_views (
consumer_name VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL COMMENT '固定消费者用途;与Inbox使用相同范围',
queue_id CHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL COMMENT '固定队列来源;由处理器事务前验证归属',
task_id CHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL COMMENT '原任务标识;每个任务建立一份版本化业务视图',
last_revision BIGINT UNSIGNED NOT NULL COMMENT '已应用最大任务版本;0仅为同事务中尚未更新的占位状态',
last_event_id CHAR(32) CHARACTER SET ascii COLLATE ascii_bin NULL COMMENT '推进当前视图的事件;占位行为空,提交后的有效视图非空',
last_state VARCHAR(24) CHARACTER SET ascii COLLATE ascii_bin NOT NULL COMMENT '冻结任务结果状态;UNINITIALIZED只用于同事务内占位',
result_json MEDIUMTEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL COMMENT '当前结果JSON;来源未提供结果时为空,保留精确数字',
applied_events BIGINT UNSIGNED NOT NULL COMMENT '实际推进视图的事件次数;重复和旧版本不增加',
created_at DATETIME(6) NOT NULL COMMENT '视图首次建立的UTC时间,微秒精度',
updated_at DATETIME(6) NOT NULL COMMENT '视图最后推进的UTC时间,微秒精度',
PRIMARY KEY (consumer_name,queue_id,task_id),
CONSTRAINT ck_receiver_revision CHECK (
(last_revision=0 AND last_event_id IS NULL AND last_state='UNINITIALIZED' AND applied_events=0)
OR (last_revision>0 AND last_event_id IS NOT NULL AND applied_events>0 AND last_state IN ('SUCCEEDED','FAILED','CANCELLED','NEEDS_REVIEW'))
),
CONSTRAINT ck_receiver_result CHECK (result_json IS NULL OR JSON_VALID(result_json))
) ENGINE=InnoDB COMMENT='业务侧任务结果视图;锁定单任务行后仅接受更高版本,防止迟到结果倒退';

View File

@@ -0,0 +1,314 @@
// Package inbox 演示业务端同事务去重和版本化结果视图,不是通用业务恰好一次框架。
package inbox
import (
"bytes"
"context"
"crypto/sha256"
"database/sql"
_ "embed"
"encoding/json"
"errors"
"io"
"regexp"
"strconv"
"strings"
"time"
"unicode/utf8"
orbit "git.yuntuo.hk/k2safe/orbit/sdk/go"
"github.com/go-sql-driver/mysql"
)
//go:embed schema.sql
var schema string
var (
ErrInvalidEvent = errors.New("receiver event or scope is invalid")
ErrEventConflict = errors.New("receiver event identity has conflicting content; investigate before acknowledgement")
ErrRevisionConflict = errors.New("receiver task revision has conflicting event identities")
ErrStorage = errors.New("receiver storage operation failed; event remains unacknowledged")
ErrCommitUnconfirmed = errors.New("receiver transaction commit is unconfirmed; reconcile original event on redelivery")
namePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`)
idPattern = regexp.MustCompile(`^[a-f0-9]{32}$`)
)
type Store struct {
db *sql.DB
consumer, queue string
}
type Result struct {
Duplicate bool // Duplicate 当前事件已在相同消费者范围内提交,内容摘要也相同。
Applied bool // Applied 原事务是否推进视图;重复命中时返回历史值,不表示再次推进。
}
// Open 仅连接接收方数据库;密码和原始DSN不进入错误输出,不自动创建或修改表。
func Open(ctx context.Context, dsn string) (*sql.DB, error) {
cfg, err := mysql.ParseDSN(dsn)
if err != nil || cfg.DBName == "" {
return nil, errors.New("valid receiver MySQL DSN with explicit database is required")
}
cfg.ParseTime = true
cfg.Loc = time.UTC
cfg.MultiStatements = false
cfg.AllowAllFiles = false
cfg.Timeout = 5 * time.Second
cfg.ReadTimeout = 8 * time.Second
cfg.WriteTimeout = 8 * time.Second
if cfg.Params == nil {
cfg.Params = map[string]string{}
}
cfg.Params["time_zone"] = "'+00:00'"
connector, err := mysql.NewConnector(cfg)
if err != nil {
return nil, ErrStorage
}
db := sql.OpenDB(connector)
db.SetMaxOpenConns(8)
db.SetMaxIdleConns(4)
db.SetConnMaxLifetime(5 * time.Minute)
if err = db.PingContext(ctx); err != nil {
db.Close()
return nil, ErrStorage
}
return db, nil
}
// Init 初始化专用空数据库。已有表会报错;升级已有业务数据库应使用接收方自己的迁移。
func Init(ctx context.Context, db *sql.DB) error {
for _, statement := range strings.Split(schema, ";") {
if strings.TrimSpace(statement) == "" {
continue
}
if _, err := db.ExecContext(ctx, statement); err != nil {
return ErrStorage
}
}
return nil
}
func New(db *sql.DB, consumer, queue string) (*Store, error) {
if db == nil || !namePattern.MatchString(consumer) || !idPattern.MatchString(queue) {
return nil, ErrInvalidEvent
}
return &Store{db: db, consumer: consumer, queue: queue}, nil
}
// Handle 可直接作为EventConsumer处理器;只有事务确认提交或命中相同已提交记录才返回nil。
func (s *Store) Handle(ctx context.Context, event orbit.ResultEvent) error {
_, err := s.Apply(ctx, event)
return err
}
// Apply 不含网络副作用。事务失败或COMMIT响应未知时不允许SDK发送ACK。
func (s *Store) Apply(parent context.Context, event orbit.ResultEvent) (Result, error) {
ctx, stop := context.WithTimeout(parent, 5*time.Second)
defer stop()
raw, digest, err := s.document(event)
if err != nil {
return Result{}, err
}
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
if err != nil {
return Result{}, ErrStorage
}
defer tx.Rollback()
_, err = tx.ExecContext(ctx, `INSERT INTO receiver_inbox(consumer_name,queue_id,event_id,event_digest,event_json,applied,received_at) VALUES(?,?,?,?,?,FALSE,UTC_TIMESTAMP(6))`, s.consumer, s.queue, event.ID, digest[:], raw)
if err != nil {
var duplicate *mysql.MySQLError
if !errors.As(err, &duplicate) || duplicate.Number != 1062 {
return Result{}, ErrStorage
}
// 唯一键冲突已等待胜出事务完成。结束本事务,再核对已提交记录;不能忽略其他SQL错误。
if err = tx.Rollback(); err != nil {
return Result{}, ErrStorage
}
var saved []byte
var applied bool
if err = s.db.QueryRowContext(ctx, `SELECT event_digest,applied FROM receiver_inbox WHERE consumer_name=? AND queue_id=? AND event_id=?`, s.consumer, s.queue, event.ID).Scan(&saved, &applied); err != nil {
return Result{}, ErrStorage
}
if !bytes.Equal(saved, digest[:]) {
return Result{}, ErrEventConflict
}
return Result{Duplicate: true, Applied: applied}, nil
}
// 原子建立或取得单任务行锁,不依赖客户端RowsAffected选项判断新旧。
_, err = tx.ExecContext(ctx, `INSERT INTO receiver_task_views(consumer_name,queue_id,task_id,last_revision,last_state,applied_events,created_at,updated_at) VALUES(?,?,?,0,'UNINITIALIZED',0,UTC_TIMESTAMP(6),UTC_TIMESTAMP(6)) ON DUPLICATE KEY UPDATE task_id=receiver_task_views.task_id`, s.consumer, s.queue, event.AggregateID)
if err != nil {
return Result{}, ErrStorage
}
var revision uint64
if err = tx.QueryRowContext(ctx, `SELECT last_revision FROM receiver_task_views WHERE consumer_name=? AND queue_id=? AND task_id=? FOR UPDATE`, s.consumer, s.queue, event.AggregateID).Scan(&revision); err != nil {
return Result{}, ErrStorage
}
if event.AggregateRevision == revision {
return Result{}, ErrRevisionConflict
}
applied := event.AggregateRevision > revision
if applied {
var result any
if len(event.Snapshot.Result) > 0 {
result = []byte(event.Snapshot.Result)
}
_, err = tx.ExecContext(ctx, `UPDATE receiver_task_views SET last_revision=?,last_event_id=?,last_state=?,result_json=?,applied_events=applied_events+1,updated_at=UTC_TIMESTAMP(6) WHERE consumer_name=? AND queue_id=? AND task_id=?`, event.AggregateRevision, event.ID, string(event.Snapshot.State), result, s.consumer, s.queue, event.AggregateID)
if err != nil {
return Result{}, ErrStorage
}
if _, err = tx.ExecContext(ctx, `UPDATE receiver_inbox SET applied=TRUE WHERE consumer_name=? AND queue_id=? AND event_id=?`, s.consumer, s.queue, event.ID); err != nil {
return Result{}, ErrStorage
}
}
if err = tx.Commit(); err != nil {
return Result{}, ErrCommitUnconfirmed
}
return Result{Applied: applied}, nil
}
func (s *Store) document(event orbit.ResultEvent) ([]byte, [32]byte, error) {
var none [32]byte
snapshot := event.Snapshot
if len(event.Details)+len(snapshot.Result) > 512<<10 {
return nil, none, ErrInvalidEvent
}
if !idPattern.MatchString(event.ID) || event.Sequence == 0 || event.AggregateKind != "task" || !idPattern.MatchString(event.AggregateID) || snapshot.TaskID != event.AggregateID || snapshot.QueueID != s.queue || event.AggregateRevision == 0 || event.AggregateRevision != snapshot.Revision || event.OccurredAt.IsZero() || snapshot.SchemaVersion != 1 || snapshot.CurrentAttemptID != nil && !idPattern.MatchString(*snapshot.CurrentAttemptID) {
return nil, none, ErrInvalidEvent
}
states := map[string]orbit.TaskState{"task.succeeded": orbit.TaskSucceeded, "task.failed": orbit.TaskFailed, "task.cancelled": orbit.TaskCancelled, "task.needs_review": orbit.TaskNeedsReview}
if state, ok := states[event.Type]; !ok || state != snapshot.State {
return nil, none, ErrInvalidEvent
}
if len(bytes.TrimSpace(event.Details)) == 0 || bytes.TrimSpace(event.Details)[0] != '{' {
return nil, none, ErrInvalidEvent
}
event.OccurredAt = event.OccurredAt.UTC()
var buffer bytes.Buffer
encoder := json.NewEncoder(&buffer)
encoder.SetEscapeHTML(false)
if encoder.Encode(event) != nil || buffer.Len() > 512<<10 {
return nil, none, ErrInvalidEvent
}
// 对象键与空白规范化,数字使用json.Number保留原精度及十进制表达。
canonical, err := canonicalJSON(buffer.Bytes())
if err != nil {
return nil, none, err
}
return canonical, sha256.Sum256(canonical), nil
}
func canonicalJSON(raw []byte) ([]byte, error) {
if !utf8.Valid(raw) || !json.Valid(raw) || !pairedUnicode(raw) {
return nil, ErrInvalidEvent
}
d := json.NewDecoder(bytes.NewReader(raw))
d.UseNumber()
var value func(int) (any, error)
value = func(depth int) (any, error) {
if depth > 32 {
return nil, ErrInvalidEvent
}
token, err := d.Token()
if err != nil {
return nil, ErrInvalidEvent
}
switch token {
case json.Delim('{'):
out := map[string]any{}
for d.More() {
key, err := d.Token()
if err != nil {
return nil, ErrInvalidEvent
}
name, ok := key.(string)
if !ok {
return nil, ErrInvalidEvent
}
if _, found := out[name]; found {
return nil, ErrInvalidEvent
}
out[name], err = value(depth + 1)
if err != nil {
return nil, err
}
}
_, err = d.Token()
return out, err
case json.Delim('['):
out := []any{}
for d.More() {
item, err := value(depth + 1)
if err != nil {
return nil, err
}
out = append(out, item)
}
_, err = d.Token()
return out, err
default:
return token, nil
}
}
doc, err := value(0)
if err != nil {
return nil, ErrInvalidEvent
}
if _, err = d.Token(); err != io.EOF {
return nil, ErrInvalidEvent
}
var out bytes.Buffer
e := json.NewEncoder(&out)
e.SetEscapeHTML(false)
if e.Encode(doc) != nil {
return nil, ErrInvalidEvent
}
return bytes.TrimSuffix(out.Bytes(), []byte{'\n'}), nil
}
// 不将未配对UTF-16转义静默转换成替代字符,避免摘要与实际业务文本失真。
func pairedUnicode(raw []byte) bool {
inside := false
for i := 0; i < len(raw); i++ {
if raw[i] == '"' {
inside = !inside
continue
}
if !inside || raw[i] != '\\' {
continue
}
i++
if i >= len(raw) {
return false
}
if raw[i] != 'u' {
continue
}
if i+4 >= len(raw) {
return false
}
n, err := strconv.ParseUint(string(raw[i+1:i+5]), 16, 16)
if err != nil {
return false
}
i += 4
if n < 0xd800 || n > 0xdfff {
continue
}
if n > 0xdbff || i+6 >= len(raw) || raw[i+1] != '\\' || raw[i+2] != 'u' {
return false
}
low, err := strconv.ParseUint(string(raw[i+3:i+7]), 16, 16)
if err != nil || low < 0xdc00 || low > 0xdfff {
return false
}
i += 6
}
return !inside
}
// View 返回业务侧当前视图;不会从Orbit重新拼装结果。
func (s *Store) View(ctx context.Context, task string) (TaskView, error) {
out := TaskView{ConsumerName: s.consumer, QueueID: s.queue, TaskID: task}
err := s.db.QueryRowContext(ctx, `SELECT last_revision,last_event_id,last_state,result_json,applied_events,created_at,updated_at FROM receiver_task_views WHERE consumer_name=? AND queue_id=? AND task_id=?`, s.consumer, s.queue, task).Scan(&out.LastRevision, &out.LastEventID, &out.LastState, &out.ResultJSON, &out.AppliedEvents, &out.CreatedAt, &out.UpdatedAt)
if err != nil {
return TaskView{}, ErrStorage
}
return out, nil
}

View File

@@ -0,0 +1,59 @@
package inbox
import (
"bytes"
"encoding/json"
"errors"
"testing"
"time"
orbit "git.yuntuo.hk/k2safe/orbit/sdk/go"
)
func newID(t *testing.T) string {
t.Helper()
id, err := orbit.NewEventMessageID()
if err != nil {
t.Fatal(err)
}
return id
}
func testEvent(t *testing.T, queue string) orbit.ResultEvent {
t.Helper()
task := newID(t)
return orbit.ResultEvent{ID: newID(t), Sequence: 7, Type: "task.succeeded", AggregateKind: "task", AggregateID: task, AggregateRevision: 9007199254740993, OccurredAt: time.Now().UTC().Truncate(time.Microsecond), Details: json.RawMessage(`{"origin":"worker"}`), Snapshot: orbit.TaskResultSnapshot{SchemaVersion: 1, TaskID: task, QueueID: queue, State: orbit.TaskSucceeded, Revision: 9007199254740993, Result: json.RawMessage(`{"n":9007199254740993,"text":"<&>"}`)}}
}
func TestFrozenEventIdentity(t *testing.T) {
s := &Store{consumer: "result-view", queue: newID(t)}
e := testEvent(t, s.queue)
raw, a, err := s.document(e)
if err != nil || !bytes.Contains(raw, []byte("9007199254740993")) || !bytes.Contains(raw, []byte("<&>")) {
t.Fatal("precision changed", err)
}
e.Snapshot.Result = json.RawMessage(`{ "text": "<&>", "n": 9007199254740993 }`)
_, b, err := s.document(e)
if err != nil || a != b {
t.Fatal("equivalent formatting changed identity", err)
}
e.OccurredAt = e.OccurredAt.In(time.FixedZone("offset", 8*3600))
_, b, err = s.document(e)
if err != nil || a != b {
t.Fatal("timezone representation changed event identity", err)
}
e.Snapshot.Result = json.RawMessage(`{"n":9007199254740994,"text":"<&>"}`)
_, b, err = s.document(e)
if err != nil || a == b {
t.Fatal("changed result not detected", err)
}
e.Snapshot.QueueID = newID(t)
if _, _, err = s.document(e); !errors.Is(err, ErrInvalidEvent) {
t.Fatal("wrong receiver scope accepted", err)
}
}
func TestCanonicalRejectsDuplicateAndInvalidJSON(t *testing.T) {
for _, raw := range []string{`{"a":1,"a":2}`, `{"a":null,"a":2}`, `{"a":1} {}`, `{"a":`, `{"a":"\ud800"}`, `{"a":"\udfff"}`, "\xff"} {
if _, err := canonicalJSON([]byte(raw)); !errors.Is(err, ErrInvalidEvent) {
t.Fatal("invalid document accepted")
}
}
}

View File

@@ -0,0 +1,9 @@
CREATE TABLE sender_jobs (
namespace_id CHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL COMMENT '与Outbox相同的命名空间范围',
operation_id CHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL COMMENT '本地业务操作标识,与Outbox同事务创建',
source_ref VARCHAR(512) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin NOT NULL COMMENT '待处理对象引用,首次入库后固定,不存对象正文或凭据',
task_id CHAR(32) CHARACTER SET ascii COLLATE ascii_bin NULL COMMENT '已确认的平台受理任务,为空不证明远端未受理',
created_at DATETIME(6) NOT NULL COMMENT '业务记录创建UTC时间,微秒精度',
updated_at DATETIME(6) NOT NULL COMMENT '关联任务最近变化UTC时间,微秒精度',
PRIMARY KEY (namespace_id,operation_id)
) ENGINE=InnoDB COMMENT='接入方示例业务记录,与待发请求及受理关联分别同事务保存';

View File

@@ -0,0 +1,126 @@
// Package outbox 演示在业务事务中使用公共 MySQL Outbox 适配包。
package jobs
import (
"context"
"database/sql"
_ "embed"
"encoding/json"
"errors"
"strings"
"unicode"
"unicode/utf8"
adapter "git.yuntuo.hk/k2safe/orbit/adapters/outbox/mysql"
orbit "git.yuntuo.hk/k2safe/orbit/sdk/go"
)
//go:embed schema.sql
var schema string
var (
ErrInvalid = adapter.ErrInvalid
ErrStorage = adapter.ErrStorage
ErrConflict = adapter.ErrConflict
ErrCommitUnconfirmed = adapter.ErrCommitUnconfirmed
ErrIdle = adapter.ErrIdle
)
type Record = adapter.Record
// Store 仅补充示例业务事务;发送、查询、租约由公共包负责。
type Store struct {
*adapter.Store
db *sql.DB
namespace string
}
var Open = adapter.Open
func Init(ctx context.Context, db *sql.DB) error {
if err := adapter.Init(ctx, db); err != nil {
return err
}
if _, err := db.ExecContext(ctx, schema); err != nil {
return ErrStorage
}
return nil
}
func New(db *sql.DB, namespace, queue string) (*Store, error) {
s, err := adapter.New(db, namespace, queue, adapter.Options{OnAccepted: accepted})
if err != nil {
return nil, err
}
return &Store{s, db, namespace}, nil
}
func accepted(ctx context.Context, tx *sql.Tx, r adapter.Record, receipt orbit.SubmitTaskResult) error {
var existing *string
if err := tx.QueryRowContext(ctx, `SELECT task_id FROM sender_jobs WHERE namespace_id=? AND operation_id=? FOR UPDATE`, r.NamespaceID, r.OperationID).Scan(&existing); err != nil {
return ErrStorage
}
if existing != nil && *existing != receipt.Task.ID {
return ErrConflict
}
_, err := tx.ExecContext(ctx, `UPDATE sender_jobs SET task_id=?,updated_at=UTC_TIMESTAMP(6) WHERE namespace_id=? AND operation_id=?`, receipt.Task.ID, r.NamespaceID, r.OperationID)
if err != nil {
return ErrStorage
}
return nil
}
func (s *Store) Enqueue(ctx context.Context, id, source string, in orbit.SubmitTaskRequest) (bool, error) {
if !utf8.ValidString(source) || strings.TrimSpace(source) == "" || utf8.RuneCountInString(source) > 512 || strings.ContainsFunc(source, unicode.IsControl) {
return false, ErrInvalid
}
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
if err != nil {
return false, ErrStorage
}
defer tx.Rollback()
duplicate, err := s.Store.EnqueueTx(ctx, tx, id, in)
if err != nil {
return false, err
}
if duplicate {
var saved string
if err = tx.QueryRowContext(ctx, `SELECT source_ref FROM sender_jobs WHERE namespace_id=? AND operation_id=? FOR UPDATE`, s.namespace, id).Scan(&saved); err != nil {
return false, ErrStorage
}
if saved != source {
return false, ErrConflict
}
} else {
if _, err = tx.ExecContext(ctx, `INSERT INTO sender_jobs(namespace_id,operation_id,source_ref,created_at,updated_at) VALUES(?,?,?,UTC_TIMESTAMP(6),UTC_TIMESTAMP(6))`, s.namespace, id, source); err != nil {
return false, ErrStorage
}
}
if tx.Commit() != nil {
return false, ErrCommitUnconfirmed
}
return duplicate, nil
}
// View 是业务HTTP的读取结果;Submission只表示提交阶段,Execution来自持久化Inbox。
type View struct {
OperationID string `json:"operation_id"` // OperationID 调用方提供的稳定操作标识。
Submission string `json:"submission"` // Submission 待发/发送中/已受理/需核查,不是执行结果。
Code string `json:"code"` // Code 最近稳定的提交分类。
TaskID *string `json:"task_id,omitempty"` // TaskID 已核对的平台任务,未确认时为空。
Execution string `json:"execution,omitempty"` // Execution 业务Inbox最后收到的任务状态。
Result json.RawMessage `json:"result,omitempty"` // Result 业务Inbox中的结果,尚未到达时为空。
}
func (s *Store) View(ctx context.Context, id string) (View, error) {
r, err := s.Status(ctx, id)
if err != nil {
return View{}, err
}
v := View{OperationID: id, Submission: r.State, Code: r.LastCode, TaskID: r.TaskID}
if r.TaskID != nil {
var result []byte
err = s.db.QueryRowContext(ctx, `SELECT last_state,result_json FROM receiver_task_views WHERE consumer_name='http-service' AND queue_id=? AND task_id=?`, r.QueueID, *r.TaskID).Scan(&v.Execution, &result)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return View{}, ErrStorage
}
v.Result = result
}
return v, nil
}