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,107 @@
package outbox
import (
"bytes"
"context"
"crypto/sha256"
"encoding/json"
"errors"
"math/rand/v2"
"time"
orbit "git.yuntuo.hk/k2safe/orbit/sdk/go"
)
// SendOnce 有界领取并发送一项;网络交换在数据库事务外,ErrIdle表示本次无到期项。
// 返回记录只包含本地发送状态;ACCEPTED不是业务执行成功,REVIEW必须人工核查原身份。
func (s *Store) SendOnce(parent context.Context, api *orbit.Client) (Record, error) {
if api == nil {
return Record{}, ErrInvalid
}
// 整个网络阶段从领取前即受单调时钟15秒约束,短于30秒租约;暂停进程后不会获得新发送预算。
work, stop := context.WithTimeout(parent, 15*time.Second)
defer stop()
r, err := s.claim(work)
if err != nil {
return Record{}, err
}
if r.State == "REVIEW" {
return s.Status(parent, r.OperationID)
}
state, code, delay, receipt := s.deliver(work, api, r)
// 网络超时不能阻止保存未知结果分类,收尾仍服从调用方总上下文且最多5秒。
if err = s.finish(parent, r, state, code, delay, receipt); err != nil {
return Record{}, err
}
return s.Status(parent, r.OperationID)
}
func (s *Store) deliver(ctx context.Context, api *orbit.Client, r Record) (string, string, time.Duration, *orbit.SubmitTaskResult) {
digest := sha256.Sum256(r.RequestJSON)
if !bytes.Equal(digest[:], r.RequestDigest) || r.NamespaceID != s.namespace || r.QueueID != s.queue || r.IdempotencyKey != "outbox-task-"+r.OperationID {
return "REVIEW", "INVALID_DOCUMENT", 0, nil
}
var in orbit.SubmitTaskRequest
decoder := json.NewDecoder(bytes.NewReader(r.RequestJSON))
decoder.DisallowUnknownFields()
if decoder.Decode(&in) != nil || in.QueueID != s.queue {
return "REVIEW", "INVALID_DOCUMENT", 0, nil
}
// 先通过当前凭据读取固定队列;不靠配置声称命名空间正确,不向错误环境盲发命令。
queue, err := api.GetQueue(ctx, s.queue)
if err != nil {
state, code, delay := classify(err, r.SendCount)
return state, code, delay, nil
}
if queue.ID != s.queue || queue.NamespaceID != s.namespace {
return "REVIEW", "TARGET_MISMATCH", 0, nil
}
// 数据库时间决定剩余截止预算;额外查询不授予新租约,换代或临近截止时不再开始HTTP。
var remaining int64
err = s.db.QueryRowContext(ctx, `SELECT TIMESTAMPDIFF(MICROSECOND,UTC_TIMESTAMP(6),LEAST(wake_at,send_deadline_at)) FROM sender_outbox WHERE namespace_id=? AND queue_id=? AND operation_id=? AND state='SENDING' AND lease_epoch=?`, s.namespace, s.queue, r.OperationID, r.LeaseEpoch).Scan(&remaining)
if err != nil || remaining <= 0 {
return "PENDING", "UNCONFIRMED", time.Second, nil
}
send, stop := context.WithTimeout(ctx, min(10*time.Second, time.Duration(remaining)*time.Microsecond))
defer stop()
result, err := api.SubmitTask(send, r.IdempotencyKey, in)
if err != nil {
state, code, delay := classify(err, r.SendCount)
return state, code, delay, nil
}
if !validReceipt(r, result) {
return "REVIEW", "INVALID_RECEIPT", 0, nil
}
return "ACCEPTED", "ACCEPTED", 0, &result
}
func validReceipt(r Record, receipt orbit.SubmitTaskResult) bool {
t := receipt.Task
return idPattern.MatchString(t.ID) && t.NamespaceID == r.NamespaceID && t.QueueID == r.QueueID && t.Revision > 0
}
func classify(err error, count uint32) (state, code string, delay time.Duration) {
state, code = "PENDING", "UNCONFIRMED"
delay = min(time.Minute, time.Second*time.Duration(uint64(1)<<min(max(count, 1)-1, 6))) + time.Duration(rand.IntN(1000))*time.Millisecond
var api *orbit.APIError
if errors.As(err, &api) {
if api.StatusCode == 429 {
code = "BACKPRESSURE"
delay = max(delay, api.RetryAfter)
} else if api.StatusCode < 500 && api.StatusCode != 408 {
state, code = "REVIEW", "HTTP_REJECTED"
if api.StatusCode == 401 || api.StatusCode == 403 {
code = "AUTHORIZATION"
}
if api.Code == "IDEMPOTENCY_CONFLICT" {
code = "IDEMPOTENCY_CONFLICT"
}
}
} else {
var httpErr *orbit.HTTPError
if errors.As(err, &httpErr) && httpErr.StatusCode < 500 && httpErr.StatusCode != 408 && httpErr.StatusCode != 429 {
state, code = "REVIEW", "HTTP_REJECTED"
}
}
return state, code, min(delay, time.Hour)
}