feat: add durable business inbox example and consumer crash recovery
This commit is contained in:
314
examples/result-inbox/internal/inbox/store.go
Normal file
314
examples/result-inbox/internal/inbox/store.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user