feat: add reusable MySQL outbox and HTTP lifecycle example
This commit is contained in:
251
examples/http-service/cmd/server/integration_test.go
Normal file
251
examples/http-service/cmd/server/integration_test.go
Normal 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")
|
||||
}
|
||||
99
examples/http-service/cmd/server/lifecycle.go
Normal file
99
examples/http-service/cmd/server/lifecycle.go
Normal 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
|
||||
}
|
||||
90
examples/http-service/cmd/server/lifecycle_test.go
Normal file
90
examples/http-service/cmd/server/lifecycle_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
234
examples/http-service/cmd/server/main.go
Normal file
234
examples/http-service/cmd/server/main.go
Normal 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)})
|
||||
}
|
||||
Reference in New Issue
Block a user