Files
orbit/examples/http-service/internal/inbox/store.go

315 lines
11 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
}