Files
orbit/adapters/outbox/mysql/sender.go

108 lines
4.1 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 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)
}