DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
commit
645b64fe21
4388 files changed
+1405457
No files matched your search
@@ -0,0 +1,432 @@
|
||||
package idempotency
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidRequest = errors.New("idempotency request is invalid")
|
||||
ErrRequestConflict = errors.New("idempotency request fingerprint conflicts with existing request")
|
||||
ErrReservationLost = errors.New("idempotency reservation ownership was lost")
|
||||
ErrStoreRequired = errors.New("idempotency store is required")
|
||||
ErrResultStoreRequired = errors.New("idempotency result store is required")
|
||||
)
|
||||
|
||||
type Status string
|
||||
|
||||
const (
|
||||
StatusAccepted Status = "accepted"
|
||||
StatusDuplicate Status = "duplicate"
|
||||
StatusInFlight Status = "in_flight"
|
||||
StatusReplay Status = "replay"
|
||||
statusPending Status = "pending"
|
||||
)
|
||||
|
||||
type Store interface {
|
||||
Check(context.Context, Request) (Decision, error)
|
||||
Snapshot() map[string]any
|
||||
}
|
||||
|
||||
type transactionalStore interface {
|
||||
Begin(context.Context, Request) (Decision, error)
|
||||
Commit(context.Context, Request, Decision) error
|
||||
Abort(context.Context, Request, Decision) error
|
||||
}
|
||||
|
||||
// resultStore 把幂等状态与可重放结果提交到同一个权威后端。
|
||||
// 它只用于 handler 已成功、不能再安全重跑的路径,避免独立响应缓存失败后留下不可恢复的 committed 状态。
|
||||
type resultStore interface {
|
||||
CommitResult(context.Context, Request, Decision, []byte) error
|
||||
LookupResult(context.Context, Decision) ([]byte, bool, error)
|
||||
}
|
||||
|
||||
type Options struct {
|
||||
TTL time.Duration
|
||||
Now func() time.Time
|
||||
Store Store
|
||||
Kind string
|
||||
}
|
||||
|
||||
type Request struct {
|
||||
Scope string
|
||||
Subject string
|
||||
Session string
|
||||
Key string
|
||||
Fingerprint string
|
||||
Sequence uint64
|
||||
Now time.Time
|
||||
// reservationToken 由 Guard.Begin 在进入后端前生成,不属于外部请求契约。
|
||||
reservationToken string
|
||||
}
|
||||
|
||||
type Decision struct {
|
||||
Status Status `json:"status"`
|
||||
Key string `json:"key,omitempty"`
|
||||
Sequence uint64 `json:"sequence,omitempty"`
|
||||
fingerprint string
|
||||
ownerToken string
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
type Reservation struct {
|
||||
guard *Guard
|
||||
request Request
|
||||
decision Decision
|
||||
active bool
|
||||
}
|
||||
|
||||
type Guard struct {
|
||||
kind string
|
||||
store Store
|
||||
stats guardStats
|
||||
}
|
||||
|
||||
type guardStats struct {
|
||||
mu sync.Mutex
|
||||
accepted int64
|
||||
duplicate int64
|
||||
inFlight int64
|
||||
replay int64
|
||||
backendError int64
|
||||
latencyNanos int64
|
||||
}
|
||||
|
||||
func New(options Options) *Guard {
|
||||
options = normalizeOptions(options)
|
||||
store := options.Store
|
||||
if store == nil {
|
||||
store = newMemoryStore(options.TTL, options.Now)
|
||||
}
|
||||
kind := strings.TrimSpace(options.Kind)
|
||||
if kind == "" {
|
||||
kind = "memory"
|
||||
}
|
||||
return &Guard{kind: kind, store: store}
|
||||
}
|
||||
|
||||
func (g *Guard) Check(ctx context.Context, request Request) (Decision, error) {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return Decision{}, err
|
||||
}
|
||||
reservation, decision, err := g.Begin(ctx, request)
|
||||
if err != nil {
|
||||
return Decision{}, err
|
||||
}
|
||||
if decision.Status == StatusAccepted {
|
||||
if err := reservation.Commit(ctx); err != nil {
|
||||
return Decision{}, err
|
||||
}
|
||||
}
|
||||
return decision, nil
|
||||
}
|
||||
|
||||
func (g *Guard) Begin(ctx context.Context, request Request) (Reservation, Decision, error) {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return Reservation{}, Decision{}, err
|
||||
}
|
||||
if g == nil || g.store == nil {
|
||||
return Reservation{}, Decision{Status: StatusAccepted}, nil
|
||||
}
|
||||
item, err := normalizeRequest(request)
|
||||
if err != nil {
|
||||
return Reservation{}, Decision{}, err
|
||||
}
|
||||
if item.Key == "" && item.Sequence == 0 {
|
||||
// 没有 key 和 sequence 的旧协议请求无法提供幂等语义,只能放行。
|
||||
// 新协议入口应尽量在网关侧派生 IdempotencyKey。
|
||||
decision := Decision{Status: StatusAccepted}
|
||||
g.stats.record(decision, nil, 0)
|
||||
return Reservation{guard: g, request: item, decision: decision}, decision, nil
|
||||
}
|
||||
ownerToken, err := newReservationToken()
|
||||
if err != nil {
|
||||
return Reservation{}, Decision{}, err
|
||||
}
|
||||
item.reservationToken = ownerToken
|
||||
start := time.Now()
|
||||
var decision Decision
|
||||
// 支持三段式后端时,Begin 只做占位;业务成功后必须 Commit,失败必须 Abort。
|
||||
// 非事务后端退化为一次 Check,用于内存或纯缓存场景。
|
||||
if store, ok := g.store.(transactionalStore); ok {
|
||||
decision, err = store.Begin(ctx, item)
|
||||
} else {
|
||||
decision, err = g.store.Check(ctx, item)
|
||||
}
|
||||
g.stats.record(decision, err, time.Since(start))
|
||||
if err != nil {
|
||||
return Reservation{}, Decision{}, err
|
||||
}
|
||||
if decision.Status == StatusAccepted {
|
||||
decision.ownerToken = ownerToken
|
||||
}
|
||||
reservation := Reservation{
|
||||
guard: g,
|
||||
request: item,
|
||||
decision: decision,
|
||||
active: decision.Status == StatusAccepted,
|
||||
}
|
||||
return reservation, decision, nil
|
||||
}
|
||||
|
||||
func (r *Reservation) Commit(ctx context.Context) error {
|
||||
if r == nil || !r.active || r.guard == nil || r.guard.store == nil {
|
||||
return nil
|
||||
}
|
||||
store, ok := r.guard.store.(transactionalStore)
|
||||
if !ok {
|
||||
r.active = false
|
||||
return nil
|
||||
}
|
||||
// Commit 只有在业务 handler 成功后调用,负责把 pending 占位转成可重放结果。
|
||||
err := store.Commit(ctx, r.request, r.decision)
|
||||
if err != nil {
|
||||
r.guard.stats.record(r.decision, err, 0)
|
||||
}
|
||||
if err == nil {
|
||||
r.active = false
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// CommitResult 原子提交幂等状态与可重放结果。
|
||||
// 默认 memory/Redis/MySQL 后端都在自身锁、Lua 或 SQL 事务内实现,不跨后端拼接伪事务。
|
||||
func (r *Reservation) CommitResult(ctx context.Context, payload []byte) error {
|
||||
if r == nil || !r.active || r.guard == nil || r.guard.store == nil {
|
||||
return nil
|
||||
}
|
||||
store, ok := r.guard.store.(resultStore)
|
||||
if !ok {
|
||||
return ErrResultStoreRequired
|
||||
}
|
||||
err := store.CommitResult(ctx, r.request, r.decision, append([]byte(nil), payload...))
|
||||
if err != nil {
|
||||
r.guard.stats.record(r.decision, err, 0)
|
||||
return err
|
||||
}
|
||||
r.active = false
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Reservation) Abort(ctx context.Context) error {
|
||||
if r == nil || !r.active || r.guard == nil || r.guard.store == nil {
|
||||
return nil
|
||||
}
|
||||
store, ok := r.guard.store.(transactionalStore)
|
||||
if !ok {
|
||||
r.active = false
|
||||
return nil
|
||||
}
|
||||
// Abort 负责清掉 pending 占位;否则客户端重试会被误认为仍在处理。
|
||||
err := store.Abort(ctx, r.request, r.decision)
|
||||
if err != nil {
|
||||
r.guard.stats.record(r.decision, err, 0)
|
||||
}
|
||||
if err == nil {
|
||||
r.active = false
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// LookupResult 读取和 committed 状态同后端、同 TTL 的可重放结果。
|
||||
// 自定义旧 Store 未实现结果契约时按未命中处理,避免破坏只使用 Check 的兼容调用方。
|
||||
func (g *Guard) LookupResult(ctx context.Context, decision Decision) ([]byte, bool, error) {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if g == nil || g.store == nil || strings.TrimSpace(decision.Key) == "" {
|
||||
return nil, false, nil
|
||||
}
|
||||
store, ok := g.store.(resultStore)
|
||||
if !ok {
|
||||
return nil, false, nil
|
||||
}
|
||||
payload, found, err := store.LookupResult(ctx, decision)
|
||||
if err != nil || !found {
|
||||
return nil, found, err
|
||||
}
|
||||
return append([]byte(nil), payload...), true, nil
|
||||
}
|
||||
|
||||
func (g *Guard) Snapshot() map[string]any {
|
||||
if g == nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
out := map[string]any{
|
||||
"kind": g.kind,
|
||||
}
|
||||
if g.store != nil {
|
||||
for key, value := range g.store.Snapshot() {
|
||||
out[key] = value
|
||||
}
|
||||
}
|
||||
out["metrics"] = g.stats.snapshot()
|
||||
return out
|
||||
}
|
||||
|
||||
func (g *Guard) Close(ctx context.Context) error {
|
||||
if g == nil || g.store == nil {
|
||||
return nil
|
||||
}
|
||||
if closer, ok := g.store.(interface {
|
||||
Close(context.Context) error
|
||||
}); ok {
|
||||
return closer.Close(ctx)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func DerivedKey(scope, subject, session string, sequence uint64) string {
|
||||
return fmt.Sprintf("v1:%s:%s:%s:%d",
|
||||
encodeDerivedKeyPart(normalizeToken(scope)),
|
||||
encodeDerivedKeyPart(normalizeToken(subject)),
|
||||
encodeDerivedKeyPart(normalizeToken(session)),
|
||||
sequence,
|
||||
)
|
||||
}
|
||||
|
||||
// CanonicalKey 对任意文本字段做长度前缀编码后生成稳定键,不改变字段大小写。
|
||||
func CanonicalKey(namespace string, parts ...string) string {
|
||||
hash := sha256.New()
|
||||
var size [8]byte
|
||||
writePart := func(part string) {
|
||||
binary.BigEndian.PutUint64(size[:], uint64(len(part)))
|
||||
_, _ = hash.Write(size[:])
|
||||
_, _ = hash.Write([]byte(part))
|
||||
}
|
||||
writePart(namespace)
|
||||
for _, part := range parts {
|
||||
writePart(part)
|
||||
}
|
||||
return "ck1_" + hex.EncodeToString(hash.Sum(nil))
|
||||
}
|
||||
|
||||
func normalizeOptions(options Options) Options {
|
||||
if options.TTL <= 0 {
|
||||
options.TTL = 10 * time.Minute
|
||||
}
|
||||
if options.Now == nil {
|
||||
options.Now = time.Now
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
func normalizeRequest(request Request) (Request, error) {
|
||||
request.Scope = normalizeToken(request.Scope)
|
||||
if request.Scope == "" {
|
||||
request.Scope = "default"
|
||||
}
|
||||
request.Subject = normalizeToken(request.Subject)
|
||||
request.Session = normalizeToken(request.Session)
|
||||
request.Key = normalizeToken(request.Key)
|
||||
request.Fingerprint = strings.TrimSpace(request.Fingerprint)
|
||||
if request.Subject == "" {
|
||||
return Request{}, fmt.Errorf("%w: subject is required", ErrInvalidRequest)
|
||||
}
|
||||
if len(request.Fingerprint) > 512 || strings.ContainsAny(request.Fingerprint, "\r\n") {
|
||||
return Request{}, fmt.Errorf("%w: fingerprint must be a single line of at most 512 bytes", ErrInvalidRequest)
|
||||
}
|
||||
if request.Sequence > maxRedisSeq {
|
||||
return Request{}, fmt.Errorf("%w: sequence must not exceed %d", ErrInvalidRequest, maxRedisSeq)
|
||||
}
|
||||
if !request.Now.IsZero() {
|
||||
request.Now = request.Now.UTC()
|
||||
}
|
||||
return request, nil
|
||||
}
|
||||
|
||||
const maxRedisSeq = uint64(1<<53 - 1)
|
||||
|
||||
func newReservationToken() (string, error) {
|
||||
var raw [16]byte
|
||||
if _, err := rand.Read(raw[:]); err != nil {
|
||||
return "", fmt.Errorf("generate idempotency reservation token: %w", err)
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(raw[:]), nil
|
||||
}
|
||||
|
||||
func resvOwnerMatch(present bool, stored, expected string) bool {
|
||||
if expected == "" {
|
||||
// 旧版 reservation 没有 owner token:只能操作同样无 token 的占位,
|
||||
// 不得回退成可删除新版占位的“万能 token”。
|
||||
return !present || stored == ""
|
||||
}
|
||||
return present && stored == expected
|
||||
}
|
||||
|
||||
func reqFPConflict(stored, incoming string) bool {
|
||||
stored = strings.TrimSpace(stored)
|
||||
incoming = strings.TrimSpace(incoming)
|
||||
return stored != "" && incoming != "" && stored != incoming
|
||||
}
|
||||
|
||||
func (s *guardStats) record(decision Decision, err error, latency time.Duration) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if err != nil {
|
||||
s.backendError++
|
||||
} else {
|
||||
switch decision.Status {
|
||||
case StatusDuplicate:
|
||||
s.duplicate++
|
||||
case StatusInFlight:
|
||||
s.inFlight++
|
||||
case StatusReplay:
|
||||
s.replay++
|
||||
default:
|
||||
s.accepted++
|
||||
}
|
||||
}
|
||||
s.latencyNanos += latency.Nanoseconds()
|
||||
}
|
||||
|
||||
func (s *guardStats) snapshot() map[string]any {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
total := s.accepted + s.duplicate + s.inFlight + s.replay + s.backendError
|
||||
avgMillis := int64(0)
|
||||
if total > 0 {
|
||||
avgMillis = s.latencyNanos / total / int64(time.Millisecond)
|
||||
}
|
||||
return map[string]any{
|
||||
"accepted": s.accepted,
|
||||
"duplicate": s.duplicate,
|
||||
"in_flight": s.inFlight,
|
||||
"replay": s.replay,
|
||||
"backend_error": s.backendError,
|
||||
"checks": total,
|
||||
"latency_ms_total": s.latencyNanos / int64(time.Millisecond),
|
||||
"latency_ms_average": avgMillis,
|
||||
}
|
||||
}
|
||||
|
||||
func sequenceScope(scope, subject, session string) string {
|
||||
return normalizeToken(scope) + "\x00" + normalizeToken(subject) + "\x00" + normalizeToken(session)
|
||||
}
|
||||
|
||||
func encodeDerivedKeyPart(value string) string {
|
||||
if value == "" {
|
||||
return "_"
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString([]byte(value))
|
||||
}
|
||||
|
||||
func normalizeToken(value string) string {
|
||||
return strings.TrimSpace(strings.ToLower(value))
|
||||
}
|
||||
|
||||
func ctxErr(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
return nil
|
||||
}
|
||||
return ctx.Err()
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
package idempotency
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type memoryStore struct {
|
||||
mu sync.Mutex
|
||||
ttl time.Duration
|
||||
now func() time.Time
|
||||
seen map[string]memorySeenEntry
|
||||
results map[string]memoryResult
|
||||
pending map[string]pendingMemoryEntry
|
||||
highest map[string]memSeqWatermark
|
||||
pendingSeq map[string]map[uint64]int
|
||||
}
|
||||
|
||||
type memoryResult struct {
|
||||
payload []byte
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
type memorySeenEntry struct {
|
||||
fingerprint string
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
type pendingMemoryEntry struct {
|
||||
fingerprint string
|
||||
ownerToken string
|
||||
expiresAt time.Time
|
||||
seqScope string
|
||||
sequence uint64
|
||||
}
|
||||
|
||||
type memSeqWatermark struct {
|
||||
sequence uint64
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
func newMemoryStore(ttl time.Duration, now func() time.Time) *memoryStore {
|
||||
if ttl <= 0 {
|
||||
ttl = 10 * time.Minute
|
||||
}
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
return &memoryStore{
|
||||
ttl: ttl,
|
||||
now: now,
|
||||
seen: make(map[string]memorySeenEntry),
|
||||
results: make(map[string]memoryResult),
|
||||
pending: make(map[string]pendingMemoryEntry),
|
||||
highest: make(map[string]memSeqWatermark),
|
||||
pendingSeq: make(map[string]map[uint64]int),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *memoryStore) Check(ctx context.Context, item Request) (Decision, error) {
|
||||
if s == nil {
|
||||
return Decision{}, ErrStoreRequired
|
||||
}
|
||||
decision, err := s.Begin(ctx, item)
|
||||
if err != nil {
|
||||
return Decision{}, err
|
||||
}
|
||||
if decision.Status == StatusAccepted {
|
||||
if err := s.Commit(ctx, item, decision); err != nil {
|
||||
return Decision{}, err
|
||||
}
|
||||
}
|
||||
return decision, nil
|
||||
}
|
||||
|
||||
func (s *memoryStore) Begin(ctx context.Context, item Request) (Decision, error) {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return Decision{}, err
|
||||
}
|
||||
if s == nil {
|
||||
return Decision{}, ErrStoreRequired
|
||||
}
|
||||
key := item.Key
|
||||
if key == "" {
|
||||
key = DerivedKey(item.Scope, item.Subject, item.Session, item.Sequence)
|
||||
}
|
||||
seqScope := sequenceScope(item.Scope, item.Subject, item.Session)
|
||||
|
||||
// Begin 只写 pending 占位:同 key 重入返回 in_flight,同序列低水位返回 duplicate/replay,handler 成功后才进入 committed。
|
||||
s.mu.Lock()
|
||||
s.ensureReadyLocked()
|
||||
now := item.Now
|
||||
if now.IsZero() {
|
||||
now = s.now().UTC()
|
||||
} else {
|
||||
now = now.UTC()
|
||||
}
|
||||
s.pruneLocked(now)
|
||||
if seen, ok := s.seen[key]; ok && seen.expiresAt.After(now) {
|
||||
if reqFPConflict(seen.fingerprint, item.Fingerprint) {
|
||||
s.mu.Unlock()
|
||||
return Decision{}, ErrRequestConflict
|
||||
}
|
||||
s.mu.Unlock()
|
||||
return Decision{Status: StatusDuplicate, Key: key, Sequence: item.Sequence}, nil
|
||||
}
|
||||
if pending, ok := s.pending[key]; ok && pending.expiresAt.After(now) {
|
||||
if reqFPConflict(pending.fingerprint, item.Fingerprint) {
|
||||
s.mu.Unlock()
|
||||
return Decision{}, ErrRequestConflict
|
||||
}
|
||||
s.mu.Unlock()
|
||||
return Decision{Status: StatusInFlight, Key: key, Sequence: item.Sequence}, nil
|
||||
}
|
||||
if item.Sequence > 0 {
|
||||
if highest, ok := s.highest[seqScope]; ok && highest.expiresAt.After(now) && highest.sequence >= item.Sequence {
|
||||
s.seen[key] = memorySeenEntry{fingerprint: item.Fingerprint, expiresAt: now.Add(s.ttl)}
|
||||
s.mu.Unlock()
|
||||
return Decision{Status: StatusReplay, Key: key, Sequence: item.Sequence}, nil
|
||||
}
|
||||
if highestPendingSeq(s.pendingSeq[seqScope]) > 0 {
|
||||
s.mu.Unlock()
|
||||
return Decision{Status: StatusInFlight, Key: key, Sequence: item.Sequence}, nil
|
||||
}
|
||||
}
|
||||
s.pending[key] = pendingMemoryEntry{fingerprint: item.Fingerprint, ownerToken: item.reservationToken, expiresAt: now.Add(s.ttl), seqScope: seqScope, sequence: item.Sequence}
|
||||
if item.Sequence > 0 {
|
||||
if s.pendingSeq[seqScope] == nil {
|
||||
s.pendingSeq[seqScope] = make(map[uint64]int)
|
||||
}
|
||||
s.pendingSeq[seqScope][item.Sequence]++
|
||||
}
|
||||
s.mu.Unlock()
|
||||
return Decision{Status: StatusAccepted, Key: key, Sequence: item.Sequence, ownerToken: item.reservationToken}, nil
|
||||
}
|
||||
|
||||
func (s *memoryStore) Commit(ctx context.Context, item Request, decision Decision) error {
|
||||
return s.commit(ctx, item, decision, nil, false)
|
||||
}
|
||||
|
||||
func (s *memoryStore) CommitResult(ctx context.Context, item Request, decision Decision, payload []byte) error {
|
||||
return s.commit(ctx, item, decision, payload, true)
|
||||
}
|
||||
|
||||
func (s *memoryStore) commit(ctx context.Context, item Request, decision Decision, payload []byte, withResult bool) error {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if s == nil {
|
||||
return ErrStoreRequired
|
||||
}
|
||||
key := decision.Key
|
||||
if key == "" {
|
||||
key = item.Key
|
||||
}
|
||||
if key == "" {
|
||||
key = DerivedKey(item.Scope, item.Subject, item.Session, item.Sequence)
|
||||
}
|
||||
seqScope := sequenceScope(item.Scope, item.Subject, item.Session)
|
||||
s.mu.Lock()
|
||||
s.ensureReadyLocked()
|
||||
// committed/result TTL 从真实提交时刻起算,不使用请求入口时间缩短重放窗口。
|
||||
now := s.now().UTC()
|
||||
s.pruneLocked(now)
|
||||
pending, pendingExists := s.pending[key]
|
||||
if !resvOwnerMatch(pendingExists, pending.ownerToken, decision.ownerToken) {
|
||||
s.mu.Unlock()
|
||||
return ErrReservationLost
|
||||
}
|
||||
if seen, ok := s.seen[key]; ok && reqFPConflict(seen.fingerprint, item.Fingerprint) {
|
||||
s.mu.Unlock()
|
||||
return ErrRequestConflict
|
||||
}
|
||||
if pendingExists && reqFPConflict(pending.fingerprint, item.Fingerprint) {
|
||||
s.mu.Unlock()
|
||||
return ErrRequestConflict
|
||||
}
|
||||
// Commit 先移除 pending,再写 seen 和最高 sequence 水位;重试包会被 duplicate/replay 拦住。
|
||||
s.removePendingLocked(key)
|
||||
s.seen[key] = memorySeenEntry{fingerprint: item.Fingerprint, expiresAt: now.Add(s.ttl)}
|
||||
if withResult {
|
||||
s.results[key] = memoryResult{payload: append([]byte(nil), payload...), expiresAt: now.Add(s.ttl)}
|
||||
} else {
|
||||
delete(s.results, key)
|
||||
}
|
||||
if item.Sequence > 0 {
|
||||
current, ok := s.highest[seqScope]
|
||||
expiresAt := now.Add(s.ttl)
|
||||
if !ok || !current.expiresAt.After(now) || current.sequence < item.Sequence {
|
||||
s.highest[seqScope] = memSeqWatermark{sequence: item.Sequence, expiresAt: expiresAt}
|
||||
} else {
|
||||
current.expiresAt = expiresAt
|
||||
s.highest[seqScope] = current
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *memoryStore) LookupResult(ctx context.Context, decision Decision) ([]byte, bool, error) {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if s == nil {
|
||||
return nil, false, ErrStoreRequired
|
||||
}
|
||||
key := decision.Key
|
||||
if key == "" {
|
||||
return nil, false, nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.ensureReadyLocked()
|
||||
now := s.now().UTC()
|
||||
s.pruneLocked(now)
|
||||
result, ok := s.results[key]
|
||||
s.mu.Unlock()
|
||||
if !ok {
|
||||
return nil, false, nil
|
||||
}
|
||||
return append([]byte(nil), result.payload...), true, nil
|
||||
}
|
||||
|
||||
func (s *memoryStore) Abort(ctx context.Context, item Request, decision Decision) error {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if s == nil {
|
||||
return ErrStoreRequired
|
||||
}
|
||||
key := decision.Key
|
||||
if key == "" {
|
||||
key = item.Key
|
||||
}
|
||||
if key == "" {
|
||||
key = DerivedKey(item.Scope, item.Subject, item.Session, item.Sequence)
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.ensureReadyLocked()
|
||||
now := s.now().UTC()
|
||||
s.pruneLocked(now)
|
||||
pending, pendingExists := s.pending[key]
|
||||
if !resvOwnerMatch(pendingExists, pending.ownerToken, decision.ownerToken) {
|
||||
s.mu.Unlock()
|
||||
return ErrReservationLost
|
||||
}
|
||||
if pendingExists && reqFPConflict(pending.fingerprint, item.Fingerprint) {
|
||||
s.mu.Unlock()
|
||||
return ErrRequestConflict
|
||||
}
|
||||
// Abort 只撤销本次 pending,不推进水位,保证 handler 失败后客户端重试仍可重新执行业务。
|
||||
s.removePendingLocked(key)
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *memoryStore) Snapshot() map[string]any {
|
||||
if s == nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.ensureReadyLocked()
|
||||
now := s.now().UTC()
|
||||
s.pruneLocked(now)
|
||||
out := map[string]any{
|
||||
"backend": "memory",
|
||||
"ttl_seconds": int64(s.ttl / time.Second),
|
||||
"seen": len(s.seen),
|
||||
"pending": len(s.pending),
|
||||
"scopes": len(s.highest),
|
||||
}
|
||||
s.mu.Unlock()
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *memoryStore) pruneLocked(now time.Time) {
|
||||
for key, seen := range s.seen {
|
||||
if !seen.expiresAt.After(now) {
|
||||
delete(s.seen, key)
|
||||
}
|
||||
}
|
||||
for key, result := range s.results {
|
||||
if !result.expiresAt.After(now) {
|
||||
delete(s.results, key)
|
||||
}
|
||||
}
|
||||
for key, pending := range s.pending {
|
||||
if !pending.expiresAt.After(now) {
|
||||
s.removePendingLocked(key)
|
||||
}
|
||||
}
|
||||
for seqScope, highest := range s.highest {
|
||||
if !highest.expiresAt.After(now) {
|
||||
delete(s.highest, seqScope)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *memoryStore) removePendingLocked(key string) {
|
||||
pending, ok := s.pending[key]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// pendingSeq 是按序列反查 in-flight 的辅助索引,删除 pending 时必须同步清理,避免后续序列误判冲突。
|
||||
delete(s.pending, key)
|
||||
if pending.sequence == 0 {
|
||||
return
|
||||
}
|
||||
sequences := s.pendingSeq[pending.seqScope]
|
||||
if sequences == nil {
|
||||
return
|
||||
}
|
||||
if sequences[pending.sequence] <= 1 {
|
||||
delete(sequences, pending.sequence)
|
||||
} else {
|
||||
sequences[pending.sequence]--
|
||||
}
|
||||
if len(sequences) == 0 {
|
||||
delete(s.pendingSeq, pending.seqScope)
|
||||
}
|
||||
}
|
||||
|
||||
func highestPendingSeq(sequences map[uint64]int) uint64 {
|
||||
var highest uint64
|
||||
for sequence, count := range sequences {
|
||||
if count > 0 && sequence > highest {
|
||||
highest = sequence
|
||||
}
|
||||
}
|
||||
return highest
|
||||
}
|
||||
|
||||
func (s *memoryStore) ensureReadyLocked() {
|
||||
if s.ttl <= 0 {
|
||||
s.ttl = 10 * time.Minute
|
||||
}
|
||||
if s.now == nil {
|
||||
s.now = time.Now
|
||||
}
|
||||
if s.seen == nil {
|
||||
s.seen = make(map[string]memorySeenEntry)
|
||||
}
|
||||
if s.results == nil {
|
||||
s.results = make(map[string]memoryResult)
|
||||
}
|
||||
if s.pending == nil {
|
||||
s.pending = make(map[string]pendingMemoryEntry)
|
||||
}
|
||||
if s.highest == nil {
|
||||
s.highest = make(map[string]memSeqWatermark)
|
||||
}
|
||||
if s.pendingSeq == nil {
|
||||
s.pendingSeq = make(map[string]map[uint64]int)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,922 @@
|
||||
package idempotency
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
mysqlRequestsTable = "idempotency_requests"
|
||||
mysqlSequencesTable = "idempotency_sequences"
|
||||
mysqlResvTable = "idempotency_sequence_reservations"
|
||||
mysqlResultsTable = "idempotency_results"
|
||||
idemRecoverTO = 3 * time.Second
|
||||
mysqlFingerprintTag = "fingerprint:v1:"
|
||||
mysqlMetadataTag = "idempotency:v2:"
|
||||
)
|
||||
|
||||
type mysqlRequestMeta struct {
|
||||
fingerprint string
|
||||
fingerprintHash string
|
||||
ownerToken string
|
||||
legacy bool
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
type MySQLOptions struct {
|
||||
DSN string
|
||||
MaxOpenConns int
|
||||
MaxIdleConns int
|
||||
ConnMaxLifetime time.Duration
|
||||
KeyPrefix string
|
||||
TTL time.Duration
|
||||
Now func() time.Time
|
||||
EnsureSchema bool
|
||||
DB *sql.DB
|
||||
CleanupInterval time.Duration
|
||||
CleanupBatchSize int
|
||||
}
|
||||
|
||||
type mySQLStore struct {
|
||||
db *sql.DB
|
||||
initErr error
|
||||
ensureSchema bool
|
||||
schemaMu sync.Mutex
|
||||
schemaReady bool
|
||||
schemaErr error
|
||||
keyPrefix string
|
||||
ttl time.Duration
|
||||
now func() time.Time
|
||||
cleanupEvery time.Duration
|
||||
cleanupLimit int
|
||||
cleanupOnce sync.Once
|
||||
cleanupMu sync.Mutex
|
||||
cleanupStop context.CancelFunc
|
||||
cleanupWG sync.WaitGroup
|
||||
closed bool
|
||||
}
|
||||
|
||||
func NewMySQL(options MySQLOptions) *Guard {
|
||||
options = normMySQLOpts(options)
|
||||
return New(Options{
|
||||
TTL: options.TTL,
|
||||
Now: options.Now,
|
||||
Kind: "mysql",
|
||||
Store: newMySQLStore(options),
|
||||
})
|
||||
}
|
||||
|
||||
func EnsureMySQLSchema(ctx context.Context, options MySQLOptions) error {
|
||||
options.EnsureSchema = true
|
||||
options.CleanupInterval = -1
|
||||
store := newMySQLStore(options)
|
||||
if options.DB == nil {
|
||||
defer func() { _ = store.Close(context.Background()) }()
|
||||
}
|
||||
return store.ready(ctx)
|
||||
}
|
||||
|
||||
func newMySQLStore(options MySQLOptions) *mySQLStore {
|
||||
options = normMySQLOpts(options)
|
||||
conn := options.DB
|
||||
var err error
|
||||
if conn == nil {
|
||||
conn, err = sql.Open("mysql", options.DSN)
|
||||
}
|
||||
if conn != nil {
|
||||
conn.SetMaxOpenConns(options.MaxOpenConns)
|
||||
conn.SetMaxIdleConns(options.MaxIdleConns)
|
||||
conn.SetConnMaxLifetime(options.ConnMaxLifetime)
|
||||
}
|
||||
return &mySQLStore{
|
||||
db: conn,
|
||||
initErr: err,
|
||||
ensureSchema: options.EnsureSchema,
|
||||
keyPrefix: options.KeyPrefix,
|
||||
ttl: options.TTL,
|
||||
now: options.Now,
|
||||
cleanupEvery: options.CleanupInterval,
|
||||
cleanupLimit: options.CleanupBatchSize,
|
||||
}
|
||||
}
|
||||
|
||||
func normMySQLOpts(options MySQLOptions) MySQLOptions {
|
||||
options.DSN = strings.TrimSpace(options.DSN)
|
||||
if options.DSN == "" {
|
||||
options.DSN = "longheng:longheng@tcp(127.0.0.1:3306)/longheng?parseTime=true&charset=utf8mb4,utf8"
|
||||
}
|
||||
options.KeyPrefix = strings.TrimSpace(options.KeyPrefix)
|
||||
if options.KeyPrefix == "" {
|
||||
options.KeyPrefix = "idempotency"
|
||||
}
|
||||
if options.MaxOpenConns <= 0 {
|
||||
options.MaxOpenConns = 32
|
||||
}
|
||||
if options.MaxIdleConns <= 0 {
|
||||
options.MaxIdleConns = 8
|
||||
}
|
||||
if options.ConnMaxLifetime <= 0 {
|
||||
options.ConnMaxLifetime = 5 * time.Minute
|
||||
}
|
||||
if options.TTL <= 0 {
|
||||
options.TTL = 10 * time.Minute
|
||||
}
|
||||
if options.Now == nil {
|
||||
options.Now = time.Now
|
||||
}
|
||||
if options.CleanupInterval == 0 {
|
||||
options.CleanupInterval = options.TTL / 2
|
||||
if options.CleanupInterval < 30*time.Second {
|
||||
options.CleanupInterval = 30 * time.Second
|
||||
}
|
||||
if options.CleanupInterval > 5*time.Minute {
|
||||
options.CleanupInterval = 5 * time.Minute
|
||||
}
|
||||
}
|
||||
if options.CleanupBatchSize <= 0 {
|
||||
options.CleanupBatchSize = 500
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
func (s *mySQLStore) Check(ctx context.Context, item Request) (Decision, error) {
|
||||
decision, err := s.Begin(ctx, item)
|
||||
if err != nil {
|
||||
return Decision{}, err
|
||||
}
|
||||
if decision.Status == StatusAccepted {
|
||||
if err := s.Commit(ctx, item, decision); err != nil {
|
||||
return Decision{}, err
|
||||
}
|
||||
}
|
||||
return decision, nil
|
||||
}
|
||||
|
||||
func (s *mySQLStore) Begin(ctx context.Context, item Request) (Decision, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if err := s.ready(ctx); err != nil {
|
||||
return Decision{}, err
|
||||
}
|
||||
now := item.Now
|
||||
if now.IsZero() {
|
||||
now = s.nowOrDefault()
|
||||
} else {
|
||||
now = now.UTC()
|
||||
}
|
||||
// Begin 在 MySQL 事务里同时检查 committed、pending 和 sequence 水位;这是多 logic 节点的权威防重入口。
|
||||
key := item.Key
|
||||
if key == "" {
|
||||
key = DerivedKey(item.Scope, item.Subject, item.Session, item.Sequence)
|
||||
}
|
||||
guardKey := s.requestKey(key)
|
||||
seqScope := sequenceScope(item.Scope, item.Subject, item.Session)
|
||||
seqKey := s.sequenceKey(seqScope)
|
||||
expiresAt := now.Add(s.ttlOrDefault())
|
||||
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return Decision{}, err
|
||||
}
|
||||
committed := false
|
||||
defer func() {
|
||||
if !committed {
|
||||
_ = tx.Rollback()
|
||||
}
|
||||
}()
|
||||
|
||||
if _, err := tx.ExecContext(ctx, //nolint:gosec // G202:SQL 只拼接框架固定表名/列名,业务值全部通过参数绑定。
|
||||
"DELETE FROM "+mysqlQuoteIdentifier(mysqlRequestsTable)+" WHERE "+mysqlQuoteIdentifier("guard_key")+" = ? AND "+mysqlQuoteIdentifier("expires_at")+" <= ?", //nolint:gosec // G202:SQL 只拼接框架固定表名/列名,业务值全部通过参数绑定。
|
||||
guardKey,
|
||||
now,
|
||||
); err != nil {
|
||||
return Decision{}, err
|
||||
}
|
||||
status, exists, err := mysqlRequestStatus(ctx, tx, guardKey, now)
|
||||
if err != nil {
|
||||
return Decision{}, err
|
||||
}
|
||||
if exists {
|
||||
metadata, _, metadataErr := mysqlRequestMetadata(ctx, tx, guardKey, now, key)
|
||||
if metadataErr != nil {
|
||||
return Decision{}, metadataErr
|
||||
}
|
||||
if metadata.conflicts(item.Fingerprint) {
|
||||
return Decision{}, ErrRequestConflict
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
if recovered, ok := s.beginDecisionRec(ctx, guardKey, now, key, item); ok {
|
||||
committed = true
|
||||
return recovered, nil
|
||||
}
|
||||
return Decision{}, err
|
||||
}
|
||||
committed = true
|
||||
return mysqlStatusDecision(status, key, item.Sequence, metadata.decisionFingerprint(item.Fingerprint), metadata.ownerToken, metadata.expiresAt), nil
|
||||
}
|
||||
if item.Sequence > 0 {
|
||||
highest, err := s.lockSequence(ctx, tx, item, seqKey, now)
|
||||
if err != nil {
|
||||
return Decision{}, err
|
||||
}
|
||||
if highest >= item.Sequence {
|
||||
if err := insertMySQLRequest(ctx, tx, guardKey, item, key, item.Fingerprint, item.reservationToken, StatusReplay, now, expiresAt); err != nil && !isMySQLDuplicate(err) {
|
||||
return Decision{}, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
if s.beginStatusRecovered(ctx, guardKey, key, now, StatusReplay, item.Fingerprint, item.reservationToken) {
|
||||
committed = true
|
||||
return Decision{Status: StatusReplay, Key: key, Sequence: item.Sequence, fingerprint: item.Fingerprint, expiresAt: expiresAt}, nil
|
||||
}
|
||||
return Decision{}, err
|
||||
}
|
||||
committed = true
|
||||
return Decision{Status: StatusReplay, Key: key, Sequence: item.Sequence, fingerprint: item.Fingerprint, expiresAt: expiresAt}, nil
|
||||
}
|
||||
pendingHighest, err := mysqlHighestSeq(ctx, tx, seqKey, now)
|
||||
if err != nil {
|
||||
return Decision{}, err
|
||||
}
|
||||
if pendingHighest > 0 {
|
||||
if err := tx.Commit(); err != nil {
|
||||
if s.beginSeqRecovered(ctx, seqKey, now) {
|
||||
committed = true
|
||||
return Decision{Status: StatusInFlight, Key: key, Sequence: item.Sequence}, nil
|
||||
}
|
||||
return Decision{}, err
|
||||
}
|
||||
committed = true
|
||||
return Decision{Status: StatusInFlight, Key: key, Sequence: item.Sequence}, nil
|
||||
}
|
||||
}
|
||||
if err := insertMySQLRequest(ctx, tx, guardKey, item, key, item.Fingerprint, item.reservationToken, statusPending, now, expiresAt); err != nil {
|
||||
if isMySQLDuplicate(err) {
|
||||
status, exists, statusErr := mysqlRequestStatus(ctx, tx, guardKey, now)
|
||||
if statusErr != nil {
|
||||
return Decision{}, statusErr
|
||||
}
|
||||
metadata, _, metadataErr := mysqlRequestMetadata(ctx, tx, guardKey, now, key)
|
||||
if metadataErr != nil {
|
||||
return Decision{}, metadataErr
|
||||
}
|
||||
if metadata.conflicts(item.Fingerprint) {
|
||||
return Decision{}, ErrRequestConflict
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
if recovered, ok := s.beginDecisionRec(ctx, guardKey, now, key, item); ok {
|
||||
committed = true
|
||||
return recovered, nil
|
||||
}
|
||||
return Decision{}, err
|
||||
}
|
||||
committed = true
|
||||
if exists {
|
||||
return mysqlStatusDecision(status, key, item.Sequence, metadata.decisionFingerprint(item.Fingerprint), metadata.ownerToken, metadata.expiresAt), nil
|
||||
}
|
||||
return Decision{Status: StatusInFlight, Key: key, Sequence: item.Sequence}, nil
|
||||
}
|
||||
return Decision{}, err
|
||||
}
|
||||
if item.Sequence > 0 {
|
||||
if err := insertMySQLReserve(ctx, tx, seqKey, guardKey, item.Sequence, expiresAt); err != nil {
|
||||
return Decision{}, err
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
if s.beginStatusRecovered(ctx, guardKey, key, now, statusPending, item.Fingerprint, item.reservationToken) {
|
||||
committed = true
|
||||
return Decision{Status: StatusAccepted, Key: key, Sequence: item.Sequence, ownerToken: item.reservationToken}, nil
|
||||
}
|
||||
return Decision{}, err
|
||||
}
|
||||
committed = true
|
||||
return Decision{Status: StatusAccepted, Key: key, Sequence: item.Sequence, ownerToken: item.reservationToken}, nil
|
||||
}
|
||||
|
||||
func (s *mySQLStore) Commit(ctx context.Context, item Request, decision Decision) error {
|
||||
_, err := s.commitWithExpiry(ctx, item, decision, nil, false)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *mySQLStore) CommitResult(ctx context.Context, item Request, decision Decision, payload []byte) error {
|
||||
_, err := s.commitWithExpiry(ctx, item, decision, payload, true)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *mySQLStore) commitWithExpiry(ctx context.Context, item Request, decision Decision, payload []byte, withResult bool) (time.Time, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if err := s.ready(ctx); err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
// committed/result 从真实提交时刻起算 TTL,不继承请求入口时间。
|
||||
now := s.nowOrDefault()
|
||||
expiresAt := now.Add(s.ttlOrDefault())
|
||||
if err := s.commitAt(ctx, item, decision, payload, withResult, now, expiresAt); err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
return expiresAt, nil
|
||||
}
|
||||
|
||||
func (s *mySQLStore) commitAt(ctx context.Context, item Request, decision Decision, payload []byte, withResult bool, now, expiresAt time.Time) error {
|
||||
// Commit 删除 reservation 并写 committed/sequence;业务成功前不能调用,否则会造成“请求未执行但重试被吞”。
|
||||
key := decision.Key
|
||||
if key == "" {
|
||||
key = item.Key
|
||||
}
|
||||
if key == "" {
|
||||
key = DerivedKey(item.Scope, item.Subject, item.Session, item.Sequence)
|
||||
}
|
||||
guardKey := s.requestKey(key)
|
||||
seqScope := sequenceScope(item.Scope, item.Subject, item.Session)
|
||||
seqKey := s.sequenceKey(seqScope)
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
committed := false
|
||||
defer func() {
|
||||
if !committed {
|
||||
_ = tx.Rollback()
|
||||
}
|
||||
}()
|
||||
status, exists, err := mysqlRequestStatus(ctx, tx, guardKey, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
metadata, _, metadataErr := mysqlRequestMetadata(ctx, tx, guardKey, now, key)
|
||||
if metadataErr != nil {
|
||||
return metadataErr
|
||||
}
|
||||
if metadata.conflicts(item.Fingerprint) {
|
||||
return ErrRequestConflict
|
||||
}
|
||||
if metadata.ownerToken != decision.ownerToken {
|
||||
return ErrReservationLost
|
||||
}
|
||||
if decision.ownerToken != "" && status != statusPending && status != StatusAccepted {
|
||||
return ErrReservationLost
|
||||
}
|
||||
} else if decision.ownerToken != "" {
|
||||
return ErrReservationLost
|
||||
}
|
||||
|
||||
if item.Sequence > 0 {
|
||||
highest, err := s.lockSequence(ctx, tx, item, seqKey, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if highest < item.Sequence {
|
||||
if _, err := tx.ExecContext(ctx, //nolint:gosec // G202:SQL 只拼接框架固定表名/列名,sequence 值通过参数绑定。
|
||||
"UPDATE "+mysqlQuoteIdentifier(mysqlSequencesTable)+" SET "+mysqlQuoteIdentifier("highest_sequence")+" = ?, "+mysqlQuoteIdentifier("updated_at")+" = ? WHERE "+mysqlQuoteIdentifier("sequence_key")+" = ?", //nolint:gosec // G202:SQL 只拼接框架固定表名/列名,sequence 值通过参数绑定。
|
||||
item.Sequence,
|
||||
now,
|
||||
seqKey,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := upsertMySQLStatus(ctx, tx, guardKey, item, key, item.Fingerprint, decision.ownerToken, StatusAccepted, now, expiresAt); err != nil {
|
||||
return err
|
||||
}
|
||||
if withResult {
|
||||
if err := upsertMySQLResult(ctx, tx, guardKey, payload, now, expiresAt); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if item.Sequence > 0 {
|
||||
if _, err := tx.ExecContext(ctx, //nolint:gosec // G202:SQL 只拼接框架固定表名/列名,业务值通过参数绑定。
|
||||
"DELETE FROM "+mysqlQuoteIdentifier(mysqlResvTable)+" WHERE "+mysqlQuoteIdentifier("guard_key")+" = ?", //nolint:gosec // G202:SQL 只拼接框架固定表名/列名,业务值通过参数绑定。
|
||||
guardKey,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
recovered := s.commitRecovered(ctx, guardKey, key, now, decision.ownerToken)
|
||||
if withResult {
|
||||
recovered = recovered && s.resultRecovered(ctx, guardKey, now, payload)
|
||||
}
|
||||
if recovered {
|
||||
committed = true
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
committed = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *mySQLStore) LookupResult(ctx context.Context, decision Decision) ([]byte, bool, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if err := s.ready(ctx); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
key := strings.TrimSpace(decision.Key)
|
||||
if key == "" {
|
||||
return nil, false, nil
|
||||
}
|
||||
payload, found, err := mysqlResultDB(ctx, s.db, s.requestKey(key), s.nowOrDefault())
|
||||
if err != nil || !found {
|
||||
return nil, found, err
|
||||
}
|
||||
return append([]byte(nil), payload...), true, nil
|
||||
}
|
||||
|
||||
func (s *mySQLStore) commitRecovered(parent context.Context, guardKey, key string, now time.Time, ownerToken string) bool {
|
||||
ctx, cancel := idemRecoverCtx(parent)
|
||||
defer cancel()
|
||||
status, exists, err := mysqlStatusDB(ctx, s.db, guardKey, now)
|
||||
if err != nil || !exists || status != StatusAccepted {
|
||||
return false
|
||||
}
|
||||
metadata, _, err := mysqlMetadataDB(ctx, s.db, guardKey, now, key)
|
||||
return err == nil && metadata.ownerToken == ownerToken
|
||||
}
|
||||
|
||||
func (s *mySQLStore) resultRecovered(parent context.Context, guardKey string, now time.Time, want []byte) bool {
|
||||
ctx, cancel := idemRecoverCtx(parent)
|
||||
defer cancel()
|
||||
payload, found, err := mysqlResultDB(ctx, s.db, guardKey, now)
|
||||
return err == nil && found && string(payload) == string(want)
|
||||
}
|
||||
|
||||
func (s *mySQLStore) beginDecisionRec(parent context.Context, guardKey string, now time.Time, key string, item Request) (Decision, bool) {
|
||||
ctx, cancel := idemRecoverCtx(parent)
|
||||
defer cancel()
|
||||
status, exists, err := mysqlStatusDB(ctx, s.db, guardKey, now)
|
||||
if err != nil || !exists {
|
||||
return Decision{}, false
|
||||
}
|
||||
metadata, _, err := mysqlMetadataDB(ctx, s.db, guardKey, now, key)
|
||||
if err != nil || metadata.conflicts(item.Fingerprint) {
|
||||
return Decision{}, false
|
||||
}
|
||||
return mysqlStatusDecision(status, key, item.Sequence, metadata.decisionFingerprint(item.Fingerprint), metadata.ownerToken, metadata.expiresAt), true
|
||||
}
|
||||
|
||||
func (s *mySQLStore) beginStatusRecovered(parent context.Context, guardKey, key string, now time.Time, want Status, fingerprint, ownerToken string) bool {
|
||||
ctx, cancel := idemRecoverCtx(parent)
|
||||
defer cancel()
|
||||
status, exists, err := mysqlStatusDB(ctx, s.db, guardKey, now)
|
||||
if err != nil || !exists || status != want {
|
||||
return false
|
||||
}
|
||||
metadata, _, err := mysqlMetadataDB(ctx, s.db, guardKey, now, key)
|
||||
return err == nil && !metadata.conflicts(fingerprint) && metadata.ownerToken == ownerToken
|
||||
}
|
||||
|
||||
func (s *mySQLStore) beginSeqRecovered(parent context.Context, seqKey string, now time.Time) bool {
|
||||
ctx, cancel := idemRecoverCtx(parent)
|
||||
defer cancel()
|
||||
highest, err := mysqlHighestSeqDB(ctx, s.db, seqKey, now)
|
||||
return err == nil && highest > 0
|
||||
}
|
||||
|
||||
func (s *mySQLStore) Abort(ctx context.Context, item Request, decision Decision) error {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if err := s.ready(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
// Abort 只删除 reservation,保留重试机会,不推进 committed 或 sequence 水位。
|
||||
key := decision.Key
|
||||
if key == "" {
|
||||
key = item.Key
|
||||
}
|
||||
if key == "" {
|
||||
key = DerivedKey(item.Scope, item.Subject, item.Session, item.Sequence)
|
||||
}
|
||||
guardKey := s.requestKey(key)
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
committed := false
|
||||
defer func() {
|
||||
if !committed {
|
||||
_ = tx.Rollback()
|
||||
}
|
||||
}()
|
||||
now := s.nowOrDefault()
|
||||
status, exists, err := mysqlRequestStatus(ctx, tx, guardKey, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
metadata, _, metadataErr := mysqlRequestMetadata(ctx, tx, guardKey, now, key)
|
||||
if metadataErr != nil {
|
||||
return metadataErr
|
||||
}
|
||||
if metadata.conflicts(item.Fingerprint) {
|
||||
return ErrRequestConflict
|
||||
}
|
||||
if status != statusPending || metadata.ownerToken != decision.ownerToken {
|
||||
return ErrReservationLost
|
||||
}
|
||||
} else if decision.ownerToken != "" {
|
||||
return ErrReservationLost
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, //nolint:gosec // G202:SQL 只拼接框架固定表名/列名,删除条件通过参数绑定。
|
||||
"DELETE FROM "+mysqlQuoteIdentifier(mysqlRequestsTable)+" WHERE "+mysqlQuoteIdentifier("guard_key")+" = ? AND "+mysqlQuoteIdentifier("status")+" = ?", //nolint:gosec // G202:SQL 只拼接框架固定表名/列名,删除条件通过参数绑定。
|
||||
guardKey,
|
||||
string(statusPending),
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, //nolint:gosec // G202:SQL 只拼接框架固定表名/列名,删除条件通过参数绑定。
|
||||
"DELETE FROM "+mysqlQuoteIdentifier(mysqlResvTable)+" WHERE "+mysqlQuoteIdentifier("guard_key")+" = ?", //nolint:gosec // G202:SQL 只拼接框架固定表名/列名,删除条件通过参数绑定。
|
||||
guardKey,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
if s.abortRecovered(ctx, guardKey, s.nowOrDefault()) {
|
||||
committed = true
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
committed = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *mySQLStore) abortRecovered(parent context.Context, guardKey string, now time.Time) bool {
|
||||
ctx, cancel := idemRecoverCtx(parent)
|
||||
defer cancel()
|
||||
status, exists, err := mysqlStatusDB(ctx, s.db, guardKey, now)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if exists && status == statusPending {
|
||||
return false
|
||||
}
|
||||
exists, err = mysqlReservationDB(ctx, s.db, guardKey, now)
|
||||
return err == nil && !exists
|
||||
}
|
||||
|
||||
func (s *mySQLStore) Snapshot() map[string]any {
|
||||
if s == nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
return map[string]any{
|
||||
"backend": "mysql",
|
||||
"ttl_seconds": int64(s.ttlOrDefault() / time.Second),
|
||||
"key_prefix": s.keyPrefixOrDefault(),
|
||||
"cleanup_interval_seconds": int64(s.cleanupEvery / time.Second),
|
||||
"cleanup_batch_size": s.cleanupLimit,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *mySQLStore) Close(context.Context) error {
|
||||
if s == nil || s.db == nil {
|
||||
return nil
|
||||
}
|
||||
s.cleanupMu.Lock()
|
||||
s.closed = true
|
||||
stop := s.cleanupStop
|
||||
s.cleanupMu.Unlock()
|
||||
if stop != nil {
|
||||
// 先停后台清理 goroutine,再关闭 DB,避免清理任务和 Close 竞争同一个连接池。
|
||||
stop()
|
||||
s.cleanupWG.Wait()
|
||||
}
|
||||
return s.db.Close()
|
||||
}
|
||||
|
||||
func (s *mySQLStore) ready(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if s == nil || s.db == nil {
|
||||
return fmt.Errorf("mysql idempotency store is nil")
|
||||
}
|
||||
if s.initErr != nil {
|
||||
return s.initErr
|
||||
}
|
||||
if s.ensureSchema {
|
||||
if err := s.ensureSchemaReady(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
s.startCleanup()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *mySQLStore) ensureSchemaReady(ctx context.Context) error {
|
||||
s.schemaMu.Lock()
|
||||
defer s.schemaMu.Unlock()
|
||||
if s.schemaReady {
|
||||
return nil
|
||||
}
|
||||
if _, err := s.db.ExecContext(ctx, mysqlReqTableDDL()); err != nil {
|
||||
s.schemaErr = err
|
||||
return err
|
||||
}
|
||||
if _, err := s.db.ExecContext(ctx, mysqlSeqTableDDL()); err != nil {
|
||||
s.schemaErr = err
|
||||
return err
|
||||
}
|
||||
if _, err := s.db.ExecContext(ctx, mysqlResvTableDDL()); err != nil {
|
||||
s.schemaErr = err
|
||||
return err
|
||||
}
|
||||
if _, err := s.db.ExecContext(ctx, mysqlResultTableDDL()); err != nil {
|
||||
s.schemaErr = err
|
||||
return err
|
||||
}
|
||||
s.schemaErr = nil
|
||||
s.schemaReady = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *mySQLStore) startCleanup() {
|
||||
if s == nil || s.cleanupEvery <= 0 || s.cleanupLimit <= 0 {
|
||||
return
|
||||
}
|
||||
s.cleanupOnce.Do(func() {
|
||||
// 清理只删除过期 pending/committed/sequence,不能影响仍在 ttl 窗口内的幂等判定。
|
||||
s.cleanupMu.Lock()
|
||||
defer s.cleanupMu.Unlock()
|
||||
if s.closed {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
s.cleanupStop = cancel
|
||||
s.cleanupWG.Add(1)
|
||||
go s.cleanupLoop(ctx)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *mySQLStore) cleanupLoop(ctx context.Context) {
|
||||
defer s.cleanupWG.Done()
|
||||
// cleanupLoop 使用独立后台 ctx;Close 会 cancel 并等待它退出,防止测试和服务停机泄漏 goroutine。
|
||||
ticker := time.NewTicker(s.cleanupEvery)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
timeout := s.cleanupEvery
|
||||
if timeout > 30*time.Second {
|
||||
timeout = 30 * time.Second
|
||||
}
|
||||
cleanupCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
_ = s.cleanupExpired(cleanupCtx)
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *mySQLStore) cleanupExpired(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if err := s.ready(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
now := s.nowOrDefault()
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
committed := false
|
||||
defer func() {
|
||||
if !committed {
|
||||
_ = tx.Rollback()
|
||||
}
|
||||
}()
|
||||
limit := s.cleanupLimit
|
||||
if limit <= 0 {
|
||||
limit = 500
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, // #nosec G202 -- SQL 只拼接框架固定表名/列名,删除条件通过参数绑定。
|
||||
mysqlCleanupSQL(mysqlResvTable, "expires_at", limit),
|
||||
now,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, // #nosec G202 -- SQL 只拼接框架固定表名/列名,过期时间通过参数绑定。
|
||||
mysqlCleanupSQL(mysqlRequestsTable, "expires_at", limit),
|
||||
now,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, // #nosec G202 -- SQL 只拼接框架固定表名/列名,过期时间通过参数绑定。
|
||||
mysqlCleanupSQL(mysqlResultsTable, "expires_at", limit),
|
||||
now,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, // #nosec G202 -- SQL 只拼接框架固定表名/列名,序列键通过参数绑定。
|
||||
mysqlCleanupSQL(mysqlSequencesTable, "updated_at", limit),
|
||||
mysqlSeqExpiryCutoff(now, s.ttlOrDefault()),
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
committed = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *mySQLStore) lockSequence(ctx context.Context, tx *sql.Tx, item Request, seqKey string, now time.Time) (uint64, error) {
|
||||
// sequence 行通过 SELECT ... FOR UPDATE 串行化同一账号/session 的序列水位,防止并发包乱序通过。
|
||||
if _, err := tx.ExecContext(ctx, //nolint:gosec // G202:SQL 只拼接框架固定表名/列名,序列清理条件通过参数绑定。
|
||||
"DELETE FROM "+mysqlQuoteIdentifier(mysqlSequencesTable)+" WHERE "+mysqlQuoteIdentifier("sequence_key")+" = ? AND "+mysqlQuoteIdentifier("updated_at")+" <= ?", //nolint:gosec // G202:SQL 只拼接框架固定表名/列名,序列清理条件通过参数绑定。
|
||||
seqKey,
|
||||
mysqlSeqExpiryCutoff(now, s.ttlOrDefault()),
|
||||
); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, //nolint:gosec // G202:SQL 只拼接框架固定表名/列名,sequence 行字段通过参数绑定。
|
||||
"INSERT INTO "+mysqlQuoteIdentifier(mysqlSequencesTable)+" ("+ //nolint:gosec // G202:SQL 只拼接框架固定表名/列名,sequence 行字段通过参数绑定。
|
||||
strings.Join([]string{
|
||||
mysqlQuoteIdentifier("sequence_key"),
|
||||
mysqlQuoteIdentifier("scope"),
|
||||
mysqlQuoteIdentifier("subject"),
|
||||
mysqlQuoteIdentifier("session_id"),
|
||||
mysqlQuoteIdentifier("highest_sequence"),
|
||||
mysqlQuoteIdentifier("updated_at"),
|
||||
}, ", ")+") VALUES (?, ?, ?, ?, 0, ?) ON DUPLICATE KEY UPDATE "+mysqlQuoteIdentifier("sequence_key")+" = "+mysqlQuoteIdentifier("sequence_key"),
|
||||
seqKey,
|
||||
limitStorageText(item.Scope, 128),
|
||||
limitStorageText(item.Subject, 128),
|
||||
limitStorageText(item.Session, 128),
|
||||
now,
|
||||
); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var highest uint64
|
||||
if err := tx.QueryRowContext(ctx,
|
||||
"SELECT "+mysqlQuoteIdentifier("highest_sequence")+" FROM "+mysqlQuoteIdentifier(mysqlSequencesTable)+" WHERE "+mysqlQuoteIdentifier("sequence_key")+" = ? FOR UPDATE",
|
||||
seqKey,
|
||||
).Scan(&highest); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return highest, nil
|
||||
}
|
||||
|
||||
func mysqlSeqExpiryCutoff(now time.Time, ttl time.Duration) time.Time {
|
||||
if ttl <= 0 {
|
||||
return now
|
||||
}
|
||||
return now.Add(-ttl)
|
||||
}
|
||||
|
||||
func mysqlRequestStatus(ctx context.Context, tx *sql.Tx, guardKey string, now time.Time) (Status, bool, error) {
|
||||
var status string
|
||||
err := tx.QueryRowContext(ctx, // #nosec G202 -- SQL 只拼接框架固定表名/列名,查询条件通过参数绑定。
|
||||
mysqlReqStatusSQL(),
|
||||
guardKey,
|
||||
now,
|
||||
).Scan(&status)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
return Status(status), true, nil
|
||||
}
|
||||
|
||||
func mysqlRequestMetadata(ctx context.Context, tx *sql.Tx, guardKey string, now time.Time, key string) (mysqlRequestMeta, bool, error) {
|
||||
var stored string
|
||||
var expiresAt time.Time
|
||||
err := tx.QueryRowContext(ctx, mysqlReqMetaSQL(), guardKey, now).Scan(&stored, &expiresAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return mysqlRequestMeta{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return mysqlRequestMeta{}, false, err
|
||||
}
|
||||
metadata := mysqlMetaFromDB(stored, key)
|
||||
metadata.expiresAt = expiresAt.UTC()
|
||||
return metadata, true, nil
|
||||
}
|
||||
|
||||
func mysqlStatusDB(ctx context.Context, db *sql.DB, guardKey string, now time.Time) (Status, bool, error) {
|
||||
if db == nil {
|
||||
return "", false, fmt.Errorf("mysql idempotency store is nil")
|
||||
}
|
||||
var status string
|
||||
err := db.QueryRowContext(ctx, mysqlReqStatusSQL(), guardKey, now).Scan(&status)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
return Status(status), true, nil
|
||||
}
|
||||
|
||||
func mysqlMetadataDB(ctx context.Context, db *sql.DB, guardKey string, now time.Time, key string) (mysqlRequestMeta, bool, error) {
|
||||
if db == nil {
|
||||
return mysqlRequestMeta{}, false, fmt.Errorf("mysql idempotency store is nil")
|
||||
}
|
||||
var stored string
|
||||
var expiresAt time.Time
|
||||
err := db.QueryRowContext(ctx, mysqlReqMetaSQL(), guardKey, now).Scan(&stored, &expiresAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return mysqlRequestMeta{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return mysqlRequestMeta{}, false, err
|
||||
}
|
||||
metadata := mysqlMetaFromDB(stored, key)
|
||||
metadata.expiresAt = expiresAt.UTC()
|
||||
return metadata, true, nil
|
||||
}
|
||||
|
||||
func mysqlResultDB(ctx context.Context, db *sql.DB, guardKey string, now time.Time) ([]byte, bool, error) {
|
||||
if db == nil {
|
||||
return nil, false, fmt.Errorf("mysql idempotency store is nil")
|
||||
}
|
||||
var payload []byte
|
||||
err := db.QueryRowContext(ctx, // #nosec G202 -- SQL 只拼接框架固定表名/列名,查询条件通过参数绑定。
|
||||
"SELECT "+mysqlQuoteIdentifier("result_data")+" FROM "+mysqlQuoteIdentifier(mysqlResultsTable)+" WHERE "+mysqlQuoteIdentifier("guard_key")+" = ? AND "+mysqlQuoteIdentifier("expires_at")+" > ? LIMIT 1", // #nosec G202 -- SQL 只拼接框架固定表名/列名,查询条件通过参数绑定。
|
||||
guardKey,
|
||||
now,
|
||||
).Scan(&payload)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return append([]byte(nil), payload...), true, nil
|
||||
}
|
||||
|
||||
func mysqlReservationDB(ctx context.Context, db *sql.DB, guardKey string, now time.Time) (bool, error) {
|
||||
if db == nil {
|
||||
return false, fmt.Errorf("mysql idempotency store is nil")
|
||||
}
|
||||
var count int
|
||||
err := db.QueryRowContext(ctx, // #nosec G202 -- SQL 只拼接框架固定表名/列名,查询条件通过参数绑定。
|
||||
"SELECT COUNT(*) FROM "+mysqlQuoteIdentifier(mysqlResvTable)+" WHERE "+mysqlQuoteIdentifier("guard_key")+" = ? AND "+mysqlQuoteIdentifier("expires_at")+" > ?", // #nosec G202 -- SQL 只拼接框架固定表名/列名,查询条件通过参数绑定。
|
||||
guardKey,
|
||||
now,
|
||||
).Scan(&count)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
func idemRecoverCtx(parent context.Context) (context.Context, context.CancelFunc) {
|
||||
base := context.Background()
|
||||
if parent != nil {
|
||||
base = context.WithoutCancel(parent)
|
||||
}
|
||||
return context.WithTimeout(base, idemRecoverTO)
|
||||
}
|
||||
|
||||
func (s *mySQLStore) requestKey(key string) string {
|
||||
return digestKey(s.keyPrefixOrDefault() + "\x00key\x00" + key)
|
||||
}
|
||||
|
||||
func (s *mySQLStore) sequenceKey(scope string) string {
|
||||
return digestKey(s.keyPrefixOrDefault() + "\x00seq\x00" + scope)
|
||||
}
|
||||
|
||||
func (s *mySQLStore) nowOrDefault() time.Time {
|
||||
if s == nil || s.now == nil {
|
||||
return time.Now().UTC()
|
||||
}
|
||||
return s.now().UTC()
|
||||
}
|
||||
|
||||
func (s *mySQLStore) ttlOrDefault() time.Duration {
|
||||
if s == nil || s.ttl <= 0 {
|
||||
return 10 * time.Minute
|
||||
}
|
||||
return s.ttl
|
||||
}
|
||||
|
||||
func (s *mySQLStore) keyPrefixOrDefault() string {
|
||||
if s == nil {
|
||||
return "idempotency"
|
||||
}
|
||||
prefix := strings.TrimSpace(s.keyPrefix)
|
||||
if prefix == "" {
|
||||
return "idempotency"
|
||||
}
|
||||
return prefix
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
package idempotency
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
mysql "github.com/go-sql-driver/mysql"
|
||||
)
|
||||
|
||||
func mysqlReqStatusSQL() string {
|
||||
return "SELECT " + mysqlQuoteIdentifier("status") + " FROM " + mysqlQuoteIdentifier(mysqlRequestsTable) + " WHERE " + mysqlQuoteIdentifier("guard_key") + " = ? AND " + mysqlQuoteIdentifier("expires_at") + " > ? LIMIT 1 FOR UPDATE"
|
||||
}
|
||||
|
||||
func mysqlReqMetaSQL() string {
|
||||
return "SELECT " + mysqlQuoteIdentifier("request_key") + ", " + mysqlQuoteIdentifier("expires_at") + " FROM " + mysqlQuoteIdentifier(mysqlRequestsTable) + " WHERE " + mysqlQuoteIdentifier("guard_key") + " = ? AND " + mysqlQuoteIdentifier("expires_at") + " > ? LIMIT 1"
|
||||
}
|
||||
|
||||
func mysqlStatusDecision(status Status, key string, sequence uint64, fingerprint, ownerToken string, expiresAt time.Time) Decision {
|
||||
switch status {
|
||||
case statusPending:
|
||||
return Decision{Status: StatusInFlight, Key: key, Sequence: sequence, fingerprint: fingerprint, ownerToken: ownerToken, expiresAt: expiresAt}
|
||||
case StatusReplay:
|
||||
return Decision{Status: StatusReplay, Key: key, Sequence: sequence, fingerprint: fingerprint, ownerToken: ownerToken, expiresAt: expiresAt}
|
||||
default:
|
||||
return Decision{Status: StatusDuplicate, Key: key, Sequence: sequence, fingerprint: fingerprint, ownerToken: ownerToken, expiresAt: expiresAt}
|
||||
}
|
||||
}
|
||||
|
||||
func mysqlHighestSeq(ctx context.Context, tx *sql.Tx, seqKey string, now time.Time) (uint64, error) {
|
||||
if _, err := tx.ExecContext(ctx, //nolint:gosec // G202:SQL 只拼接框架固定表名/列名,过期时间通过参数绑定。
|
||||
"DELETE FROM "+mysqlQuoteIdentifier(mysqlResvTable)+" WHERE "+mysqlQuoteIdentifier("expires_at")+" <= ?", //nolint:gosec // G202:SQL 只拼接框架固定表名/列名,过期时间通过参数绑定。
|
||||
now,
|
||||
); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var highest uint64
|
||||
err := tx.QueryRowContext(ctx,
|
||||
"SELECT COALESCE(MAX("+mysqlQuoteIdentifier("sequence_num")+"), 0) FROM "+mysqlQuoteIdentifier(mysqlResvTable)+" WHERE "+mysqlQuoteIdentifier("sequence_key")+" = ? AND "+mysqlQuoteIdentifier("expires_at")+" > ?",
|
||||
seqKey,
|
||||
now,
|
||||
).Scan(&highest)
|
||||
return highest, err
|
||||
}
|
||||
|
||||
func mysqlHighestSeqDB(ctx context.Context, db *sql.DB, seqKey string, now time.Time) (uint64, error) {
|
||||
if db == nil {
|
||||
return 0, fmt.Errorf("mysql idempotency store is nil")
|
||||
}
|
||||
var highest uint64
|
||||
err := db.QueryRowContext(ctx,
|
||||
"SELECT COALESCE(MAX("+mysqlQuoteIdentifier("sequence_num")+"), 0) FROM "+mysqlQuoteIdentifier(mysqlResvTable)+" WHERE "+mysqlQuoteIdentifier("sequence_key")+" = ? AND "+mysqlQuoteIdentifier("expires_at")+" > ?",
|
||||
seqKey,
|
||||
now,
|
||||
).Scan(&highest)
|
||||
return highest, err
|
||||
}
|
||||
|
||||
func insertMySQLRequest(ctx context.Context, tx *sql.Tx, guardKey string, item Request, requestKey, fingerprint, ownerToken string, status Status, now, expiresAt time.Time) error {
|
||||
_, err := tx.ExecContext(ctx, //nolint:gosec // G202:SQL 只拼接框架固定表名/列名,请求字段通过参数绑定。
|
||||
"INSERT INTO "+mysqlQuoteIdentifier(mysqlRequestsTable)+" ("+ //nolint:gosec // G202:SQL 只拼接框架固定表名/列名,请求字段通过参数绑定。
|
||||
strings.Join([]string{
|
||||
mysqlQuoteIdentifier("guard_key"),
|
||||
mysqlQuoteIdentifier("scope"),
|
||||
mysqlQuoteIdentifier("subject"),
|
||||
mysqlQuoteIdentifier("session_id"),
|
||||
mysqlQuoteIdentifier("request_key"),
|
||||
mysqlQuoteIdentifier("sequence_num"),
|
||||
mysqlQuoteIdentifier("status"),
|
||||
mysqlQuoteIdentifier("created_at"),
|
||||
mysqlQuoteIdentifier("expires_at"),
|
||||
}, ", ")+") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
guardKey,
|
||||
limitStorageText(item.Scope, 128),
|
||||
limitStorageText(item.Subject, 128),
|
||||
limitStorageText(item.Session, 128),
|
||||
mysqlMetadataStorage(requestKey, fingerprint, ownerToken),
|
||||
item.Sequence,
|
||||
string(status),
|
||||
now,
|
||||
expiresAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func upsertMySQLStatus(ctx context.Context, tx *sql.Tx, guardKey string, item Request, requestKey, fingerprint, ownerToken string, status Status, now, expiresAt time.Time) error {
|
||||
result, err := tx.ExecContext(ctx, //nolint:gosec // G202:SQL 只拼接框架固定表名/列名,状态字段通过参数绑定。
|
||||
"UPDATE "+mysqlQuoteIdentifier(mysqlRequestsTable)+" SET "+mysqlQuoteIdentifier("status")+" = ?, "+mysqlQuoteIdentifier("expires_at")+" = ? WHERE "+mysqlQuoteIdentifier("guard_key")+" = ?", //nolint:gosec // G202:SQL 只拼接框架固定表名/列名,状态字段通过参数绑定。
|
||||
string(status),
|
||||
expiresAt,
|
||||
guardKey,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if affected > 0 {
|
||||
return nil
|
||||
}
|
||||
insertErr := insertMySQLRequest(ctx, tx, guardKey, item, requestKey, fingerprint, ownerToken, status, now, expiresAt)
|
||||
if insertErr != nil && !isMySQLDuplicate(insertErr) {
|
||||
return insertErr
|
||||
}
|
||||
if isMySQLDuplicate(insertErr) {
|
||||
_, err = tx.ExecContext(ctx, //nolint:gosec // G202:SQL 只拼接框架固定表名/列名,状态字段通过参数绑定。
|
||||
"UPDATE "+mysqlQuoteIdentifier(mysqlRequestsTable)+" SET "+mysqlQuoteIdentifier("status")+" = ?, "+mysqlQuoteIdentifier("expires_at")+" = ? WHERE "+mysqlQuoteIdentifier("guard_key")+" = ?", //nolint:gosec // G202:SQL 只拼接框架固定表名/列名,状态字段通过参数绑定。
|
||||
string(status),
|
||||
expiresAt,
|
||||
guardKey,
|
||||
)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func mysqlMetadataStorage(requestKey, fingerprint, ownerToken string) string {
|
||||
fingerprint = strings.TrimSpace(fingerprint)
|
||||
ownerToken = strings.TrimSpace(ownerToken)
|
||||
if fingerprint == "" && ownerToken == "" {
|
||||
// 直接调用旧 Store 的路径保留 request_key 原义;新 Guard 总会携带 owner token。
|
||||
return limitStorageText(requestKey, 512)
|
||||
}
|
||||
hash := ""
|
||||
if fingerprint != "" {
|
||||
sum := sha256.Sum256([]byte(fingerprint))
|
||||
hash = hex.EncodeToString(sum[:])
|
||||
}
|
||||
return mysqlMetadataTag + hash + ":" + ownerToken
|
||||
}
|
||||
|
||||
func mysqlMetaFromDB(value, requestKey string) mysqlRequestMeta {
|
||||
if value == limitStorageText(requestKey, 512) {
|
||||
return mysqlRequestMeta{legacy: true}
|
||||
}
|
||||
if strings.HasPrefix(value, mysqlMetadataTag) {
|
||||
hash, ownerToken, ok := strings.Cut(strings.TrimPrefix(value, mysqlMetadataTag), ":")
|
||||
if ok && (hash == "" || len(hash) == sha256.Size*2) {
|
||||
return mysqlRequestMeta{fingerprintHash: hash, ownerToken: ownerToken}
|
||||
}
|
||||
return mysqlRequestMeta{legacy: true}
|
||||
}
|
||||
if strings.HasPrefix(value, mysqlFingerprintTag) {
|
||||
return mysqlRequestMeta{fingerprint: strings.TrimPrefix(value, mysqlFingerprintTag)}
|
||||
}
|
||||
return mysqlRequestMeta{legacy: true}
|
||||
}
|
||||
|
||||
func (m mysqlRequestMeta) conflicts(incoming string) bool {
|
||||
incoming = strings.TrimSpace(incoming)
|
||||
if incoming == "" {
|
||||
return false
|
||||
}
|
||||
if m.fingerprint != "" {
|
||||
return m.fingerprint != incoming
|
||||
}
|
||||
if m.fingerprintHash != "" {
|
||||
sum := sha256.Sum256([]byte(incoming))
|
||||
return m.fingerprintHash != hex.EncodeToString(sum[:])
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (m mysqlRequestMeta) decisionFingerprint(incoming string) string {
|
||||
if m.legacy {
|
||||
return ""
|
||||
}
|
||||
if m.fingerprint != "" {
|
||||
return m.fingerprint
|
||||
}
|
||||
if m.fingerprintHash != "" && !m.conflicts(incoming) {
|
||||
return strings.TrimSpace(incoming)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func upsertMySQLResult(ctx context.Context, tx *sql.Tx, guardKey string, payload []byte, now, expiresAt time.Time) error {
|
||||
_, err := tx.ExecContext(ctx, //nolint:gosec // G202:SQL 只拼接框架固定表名/列名,结果内容通过参数绑定。
|
||||
"INSERT INTO "+mysqlQuoteIdentifier(mysqlResultsTable)+" ("+ //nolint:gosec // G202:SQL 只拼接框架固定表名/列名,结果内容通过参数绑定。
|
||||
strings.Join([]string{
|
||||
mysqlQuoteIdentifier("guard_key"),
|
||||
mysqlQuoteIdentifier("result_data"),
|
||||
mysqlQuoteIdentifier("created_at"),
|
||||
mysqlQuoteIdentifier("expires_at"),
|
||||
}, ", ")+") VALUES (?, ?, ?, ?) ON DUPLICATE KEY UPDATE "+
|
||||
mysqlQuoteIdentifier("result_data")+" = VALUES("+mysqlQuoteIdentifier("result_data")+"), "+
|
||||
mysqlQuoteIdentifier("expires_at")+" = VALUES("+mysqlQuoteIdentifier("expires_at")+")",
|
||||
guardKey,
|
||||
payload,
|
||||
now,
|
||||
expiresAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func insertMySQLReserve(ctx context.Context, tx *sql.Tx, seqKey, guardKey string, sequence uint64, expiresAt time.Time) error {
|
||||
_, err := tx.ExecContext(ctx, //nolint:gosec // G202:SQL 只拼接框架固定表名/列名,reservation 字段通过参数绑定。
|
||||
"INSERT INTO "+mysqlQuoteIdentifier(mysqlResvTable)+" ("+ //nolint:gosec // G202:SQL 只拼接框架固定表名/列名,reservation 字段通过参数绑定。
|
||||
strings.Join([]string{
|
||||
mysqlQuoteIdentifier("sequence_key"),
|
||||
mysqlQuoteIdentifier("sequence_num"),
|
||||
mysqlQuoteIdentifier("guard_key"),
|
||||
mysqlQuoteIdentifier("expires_at"),
|
||||
}, ", ")+") VALUES (?, ?, ?, ?)",
|
||||
seqKey,
|
||||
sequence,
|
||||
guardKey,
|
||||
expiresAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func mysqlReqTableDDL() string {
|
||||
return "CREATE TABLE IF NOT EXISTS " + mysqlQuoteIdentifier(mysqlRequestsTable) + " (\n" +
|
||||
" " + mysqlQuoteIdentifier("guard_key") + " CHAR(64) NOT NULL,\n" +
|
||||
" " + mysqlQuoteIdentifier("scope") + " VARCHAR(128) NOT NULL,\n" +
|
||||
" " + mysqlQuoteIdentifier("subject") + " VARCHAR(128) NOT NULL,\n" +
|
||||
" " + mysqlQuoteIdentifier("session_id") + " VARCHAR(128) NOT NULL DEFAULT '',\n" +
|
||||
" " + mysqlQuoteIdentifier("request_key") + " VARCHAR(512) NOT NULL,\n" +
|
||||
" " + mysqlQuoteIdentifier("sequence_num") + " BIGINT UNSIGNED NOT NULL DEFAULT 0,\n" +
|
||||
" " + mysqlQuoteIdentifier("status") + " VARCHAR(32) NOT NULL,\n" +
|
||||
" " + mysqlQuoteIdentifier("created_at") + " DATETIME(6) NOT NULL,\n" +
|
||||
" " + mysqlQuoteIdentifier("expires_at") + " DATETIME(6) NOT NULL,\n" +
|
||||
" PRIMARY KEY (" + mysqlQuoteIdentifier("guard_key") + "),\n" +
|
||||
" KEY " + mysqlQuoteIdentifier("idx_idempotency_requests_expires_at") + " (" + mysqlQuoteIdentifier("expires_at") + "),\n" +
|
||||
" KEY " + mysqlQuoteIdentifier("idx_idempotency_requests_subject") + " (" + mysqlQuoteIdentifier("scope") + ", " + mysqlQuoteIdentifier("subject") + ")\n" +
|
||||
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||||
}
|
||||
|
||||
func mysqlSeqTableDDL() string {
|
||||
return "CREATE TABLE IF NOT EXISTS " + mysqlQuoteIdentifier(mysqlSequencesTable) + " (\n" +
|
||||
" " + mysqlQuoteIdentifier("sequence_key") + " CHAR(64) NOT NULL,\n" +
|
||||
" " + mysqlQuoteIdentifier("scope") + " VARCHAR(128) NOT NULL,\n" +
|
||||
" " + mysqlQuoteIdentifier("subject") + " VARCHAR(128) NOT NULL,\n" +
|
||||
" " + mysqlQuoteIdentifier("session_id") + " VARCHAR(128) NOT NULL DEFAULT '',\n" +
|
||||
" " + mysqlQuoteIdentifier("highest_sequence") + " BIGINT UNSIGNED NOT NULL DEFAULT 0,\n" +
|
||||
" " + mysqlQuoteIdentifier("updated_at") + " DATETIME(6) NOT NULL,\n" +
|
||||
" PRIMARY KEY (" + mysqlQuoteIdentifier("sequence_key") + "),\n" +
|
||||
" KEY " + mysqlQuoteIdentifier("idx_idempotency_sequences_subject") + " (" + mysqlQuoteIdentifier("scope") + ", " + mysqlQuoteIdentifier("subject") + "),\n" +
|
||||
" KEY " + mysqlQuoteIdentifier("idx_idempotency_sequences_updated_at") + " (" + mysqlQuoteIdentifier("updated_at") + ")\n" +
|
||||
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||||
}
|
||||
|
||||
func mysqlResvTableDDL() string {
|
||||
return "CREATE TABLE IF NOT EXISTS " + mysqlQuoteIdentifier(mysqlResvTable) + " (\n" +
|
||||
" " + mysqlQuoteIdentifier("sequence_key") + " CHAR(64) NOT NULL,\n" +
|
||||
" " + mysqlQuoteIdentifier("sequence_num") + " BIGINT UNSIGNED NOT NULL,\n" +
|
||||
" " + mysqlQuoteIdentifier("guard_key") + " CHAR(64) NOT NULL,\n" +
|
||||
" " + mysqlQuoteIdentifier("expires_at") + " DATETIME(6) NOT NULL,\n" +
|
||||
" PRIMARY KEY (" + mysqlQuoteIdentifier("sequence_key") + ", " + mysqlQuoteIdentifier("sequence_num") + "),\n" +
|
||||
" UNIQUE KEY " + mysqlQuoteIdentifier("idx_idempotency_reservations_guard_key") + " (" + mysqlQuoteIdentifier("guard_key") + "),\n" +
|
||||
" KEY " + mysqlQuoteIdentifier("idx_idempotency_reservations_expires_at") + " (" + mysqlQuoteIdentifier("expires_at") + ")\n" +
|
||||
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||||
}
|
||||
|
||||
func mysqlResultTableDDL() string {
|
||||
return "CREATE TABLE IF NOT EXISTS " + mysqlQuoteIdentifier(mysqlResultsTable) + " (\n" +
|
||||
" " + mysqlQuoteIdentifier("guard_key") + " CHAR(64) NOT NULL,\n" +
|
||||
" " + mysqlQuoteIdentifier("result_data") + " LONGBLOB NOT NULL,\n" +
|
||||
" " + mysqlQuoteIdentifier("created_at") + " DATETIME(6) NOT NULL,\n" +
|
||||
" " + mysqlQuoteIdentifier("expires_at") + " DATETIME(6) NOT NULL,\n" +
|
||||
" PRIMARY KEY (" + mysqlQuoteIdentifier("guard_key") + "),\n" +
|
||||
" KEY " + mysqlQuoteIdentifier("idx_idempotency_results_expires_at") + " (" + mysqlQuoteIdentifier("expires_at") + ")\n" +
|
||||
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||||
}
|
||||
|
||||
func mysqlQuoteIdentifier(identifier string) string {
|
||||
return "`" + strings.ReplaceAll(identifier, "`", "``") + "`"
|
||||
}
|
||||
|
||||
func mysqlCleanupSQL(table, column string, limit int) string {
|
||||
if limit <= 0 {
|
||||
limit = 500
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"DELETE FROM %s WHERE %s <= ? ORDER BY %s LIMIT %d",
|
||||
mysqlQuoteIdentifier(table),
|
||||
mysqlQuoteIdentifier(column),
|
||||
mysqlQuoteIdentifier(column),
|
||||
limit,
|
||||
)
|
||||
}
|
||||
|
||||
func isMySQLDuplicate(err error) bool {
|
||||
var mysqlErr *mysql.MySQLError
|
||||
return errors.As(err, &mysqlErr) && mysqlErr.Number == 1062
|
||||
}
|
||||
|
||||
func limitStorageText(value string, limit int) string {
|
||||
if limit <= 0 || len(value) <= limit {
|
||||
return value
|
||||
}
|
||||
last := 0
|
||||
for idx := range value {
|
||||
if idx > limit {
|
||||
return value[:last]
|
||||
}
|
||||
last = idx
|
||||
}
|
||||
return value[:last]
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
package idempotency
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type MySQLRedisOptions struct {
|
||||
Redis RedisOptions
|
||||
MySQL MySQLOptions
|
||||
TTL time.Duration
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
type mySQLRedisStore struct {
|
||||
redis *redisStore
|
||||
mysql Store
|
||||
ttl time.Duration
|
||||
now func() time.Time
|
||||
stats mySQLRedisStats
|
||||
}
|
||||
|
||||
type mySQLRedisStats struct {
|
||||
mu sync.Mutex
|
||||
redisDuplicate int64
|
||||
redisReplay int64
|
||||
redisError int64
|
||||
redisBackfill int64
|
||||
redisCommitError int64
|
||||
mysqlFallback int64
|
||||
}
|
||||
|
||||
func NewMySQLRedis(options MySQLRedisOptions) *Guard {
|
||||
options = normMySQLRedisOpts(options)
|
||||
redisOptions := options.Redis
|
||||
redisOptions.TTL = options.TTL
|
||||
redisOptions.Now = options.Now
|
||||
redisStore := newRedisOpts(redisOptions)
|
||||
|
||||
mysqlOptions := options.MySQL
|
||||
mysqlOptions.TTL = options.TTL
|
||||
mysqlOptions.Now = options.Now
|
||||
mysqlOptions.EnsureSchema = true
|
||||
mysqlStore := newMySQLStore(mysqlOptions)
|
||||
|
||||
return New(Options{
|
||||
TTL: options.TTL,
|
||||
Now: options.Now,
|
||||
Kind: "mysql_redis",
|
||||
Store: &mySQLRedisStore{
|
||||
redis: redisStore,
|
||||
mysql: mysqlStore,
|
||||
ttl: options.TTL,
|
||||
now: options.Now,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func normMySQLRedisOpts(options MySQLRedisOptions) MySQLRedisOptions {
|
||||
if options.TTL <= 0 {
|
||||
options.TTL = 10 * time.Minute
|
||||
}
|
||||
if options.Now == nil {
|
||||
options.Now = time.Now
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
func (s *mySQLRedisStore) Check(ctx context.Context, item Request) (Decision, error) {
|
||||
decision, err := s.Begin(ctx, item)
|
||||
if err != nil {
|
||||
return Decision{}, err
|
||||
}
|
||||
if decision.Status == StatusAccepted {
|
||||
if err := s.Commit(ctx, item, decision); err != nil {
|
||||
return Decision{}, err
|
||||
}
|
||||
}
|
||||
return decision, nil
|
||||
}
|
||||
|
||||
func (s *mySQLRedisStore) Begin(ctx context.Context, item Request) (Decision, error) {
|
||||
if s == nil || s.mysql == nil {
|
||||
return Decision{}, ErrInvalidRequest
|
||||
}
|
||||
// Redis 只查询已经提交完成的热结果;pending/accepted 状态以 MySQL 为准。
|
||||
// 这样可以避免“Redis 先接受、MySQL 失败”导致重试被缓存误丢。
|
||||
if s.redis != nil {
|
||||
redisDecision, hit, err := s.redis.LookupCommitted(ctx, item)
|
||||
if errors.Is(err, ErrRequestConflict) {
|
||||
return Decision{}, err
|
||||
} else if err != nil {
|
||||
s.stats.recordRedisError()
|
||||
} else if hit {
|
||||
s.stats.recordRedisDecision(redisDecision.Status)
|
||||
return redisDecision, nil
|
||||
}
|
||||
}
|
||||
s.stats.recordMySQLFallback()
|
||||
store, ok := s.mysql.(transactionalStore)
|
||||
if !ok {
|
||||
return Decision{}, ErrInvalidRequest
|
||||
}
|
||||
decision, err := store.Begin(ctx, item)
|
||||
if err != nil {
|
||||
return Decision{}, err
|
||||
}
|
||||
// MySQL 判定为 duplicate/replay 后再回填 Redis,用缓存加速后续重试。
|
||||
if s.redis != nil && (decision.Status == StatusDuplicate || decision.Status == StatusReplay) {
|
||||
cacheItem := item
|
||||
cacheItem.Fingerprint = decision.fingerprint
|
||||
if err := s.backfillRedis(ctx, cacheItem, decision, decision.expiresAt); err != nil {
|
||||
s.stats.recordRedisErr()
|
||||
} else {
|
||||
s.stats.recordRedisBackfill()
|
||||
}
|
||||
}
|
||||
return decision, nil
|
||||
}
|
||||
|
||||
func (s *mySQLRedisStore) Commit(ctx context.Context, item Request, decision Decision) error {
|
||||
if s == nil || s.mysql == nil {
|
||||
return ErrInvalidRequest
|
||||
}
|
||||
store, ok := s.mysql.(transactionalStore)
|
||||
if !ok {
|
||||
return ErrInvalidRequest
|
||||
}
|
||||
var expiresAt time.Time
|
||||
if mysqlStore, ok := s.mysql.(*mySQLStore); ok {
|
||||
var err error
|
||||
expiresAt, err = mysqlStore.commitWithExpiry(ctx, item, decision, nil, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := store.Commit(ctx, item, decision); err != nil {
|
||||
return err
|
||||
}
|
||||
expiresAt = s.nowOrDefault().Add(s.ttlOrDefault())
|
||||
}
|
||||
// 提交顺序必须是 MySQL 先成功、Redis 后回填;Redis 回填失败只影响加速,不影响权威状态。
|
||||
if s.redis != nil {
|
||||
if err := s.backfillRedis(ctx, item, decision, expiresAt); err != nil {
|
||||
s.stats.recordRedisErr()
|
||||
} else {
|
||||
s.stats.recordRedisBackfill()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *mySQLRedisStore) CommitResult(ctx context.Context, item Request, decision Decision, payload []byte) error {
|
||||
if s == nil || s.mysql == nil {
|
||||
return ErrInvalidRequest
|
||||
}
|
||||
store, ok := s.mysql.(resultStore)
|
||||
if !ok {
|
||||
return ErrResultStoreRequired
|
||||
}
|
||||
// MySQL 在一个事务里提交 marker 与结果;Redis 仍只做 committed 热索引,失败不影响权威结果。
|
||||
var expiresAt time.Time
|
||||
if mysqlStore, ok := s.mysql.(*mySQLStore); ok {
|
||||
var err error
|
||||
expiresAt, err = mysqlStore.commitWithExpiry(ctx, item, decision, payload, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := store.CommitResult(ctx, item, decision, payload); err != nil {
|
||||
return err
|
||||
}
|
||||
expiresAt = s.nowOrDefault().Add(s.ttlOrDefault())
|
||||
}
|
||||
if s.redis != nil {
|
||||
if err := s.backfillRedis(ctx, item, decision, expiresAt); err != nil {
|
||||
s.stats.recordRedisErr()
|
||||
} else {
|
||||
s.stats.recordRedisBackfill()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *mySQLRedisStore) backfillRedis(ctx context.Context, item Request, decision Decision, expiresAt time.Time) error {
|
||||
if s == nil || s.redis == nil {
|
||||
return nil
|
||||
}
|
||||
ttl := s.ttlOrDefault()
|
||||
if !expiresAt.IsZero() {
|
||||
ttl = expiresAt.Sub(s.nowOrDefault())
|
||||
}
|
||||
return s.redis.storeCommittedTTL(ctx, item, decision, ttl)
|
||||
}
|
||||
|
||||
func (s *mySQLRedisStore) ttlOrDefault() time.Duration {
|
||||
if s == nil || s.ttl <= 0 {
|
||||
return 10 * time.Minute
|
||||
}
|
||||
return s.ttl
|
||||
}
|
||||
|
||||
func (s *mySQLRedisStore) nowOrDefault() time.Time {
|
||||
if s != nil && s.now == nil {
|
||||
if mysqlStore, ok := s.mysql.(*mySQLStore); ok {
|
||||
return mysqlStore.nowOrDefault()
|
||||
}
|
||||
}
|
||||
if s == nil || s.now == nil {
|
||||
return time.Now().UTC()
|
||||
}
|
||||
return s.now().UTC()
|
||||
}
|
||||
|
||||
func (s *mySQLRedisStore) LookupResult(ctx context.Context, decision Decision) ([]byte, bool, error) {
|
||||
if s == nil || s.mysql == nil {
|
||||
return nil, false, ErrInvalidRequest
|
||||
}
|
||||
store, ok := s.mysql.(resultStore)
|
||||
if !ok {
|
||||
return nil, false, ErrResultStoreRequired
|
||||
}
|
||||
return store.LookupResult(ctx, decision)
|
||||
}
|
||||
|
||||
func (s *mySQLRedisStore) Abort(ctx context.Context, item Request, decision Decision) error {
|
||||
if s == nil || s.mysql == nil {
|
||||
return ErrInvalidRequest
|
||||
}
|
||||
store, ok := s.mysql.(transactionalStore)
|
||||
if !ok {
|
||||
return ErrInvalidRequest
|
||||
}
|
||||
return store.Abort(ctx, item, decision)
|
||||
}
|
||||
|
||||
func (s *mySQLRedisStore) Snapshot() map[string]any {
|
||||
out := map[string]any{
|
||||
"backend": "mysql_redis",
|
||||
}
|
||||
if s == nil {
|
||||
return out
|
||||
}
|
||||
if s.redis != nil {
|
||||
out["redis"] = s.redis.Snapshot()
|
||||
}
|
||||
if s.mysql != nil {
|
||||
out["mysql"] = s.mysql.Snapshot()
|
||||
}
|
||||
out["hybrid_metrics"] = s.stats.snapshot()
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *mySQLRedisStore) Close(ctx context.Context) error {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
var first error
|
||||
if s.redis != nil {
|
||||
first = s.redis.Close(ctx)
|
||||
}
|
||||
if s.mysql != nil {
|
||||
if closer, ok := s.mysql.(interface {
|
||||
Close(context.Context) error
|
||||
}); ok {
|
||||
if err := closer.Close(ctx); first == nil {
|
||||
first = err
|
||||
}
|
||||
}
|
||||
}
|
||||
return first
|
||||
}
|
||||
|
||||
func (s *mySQLRedisStats) recordRedisDecision(status Status) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
switch status {
|
||||
case StatusDuplicate:
|
||||
s.redisDuplicate++
|
||||
case StatusReplay:
|
||||
s.redisReplay++
|
||||
}
|
||||
}
|
||||
|
||||
func (s *mySQLRedisStats) recordRedisError() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.redisError++
|
||||
}
|
||||
|
||||
func (s *mySQLRedisStats) recordRedisBackfill() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.redisBackfill++
|
||||
}
|
||||
|
||||
func (s *mySQLRedisStats) recordRedisErr() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.redisCommitError++
|
||||
}
|
||||
|
||||
func (s *mySQLRedisStats) recordMySQLFallback() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.mysqlFallback++
|
||||
}
|
||||
|
||||
func (s *mySQLRedisStats) snapshot() map[string]any {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return map[string]any{
|
||||
"redis_duplicate": s.redisDuplicate,
|
||||
"redis_replay": s.redisReplay,
|
||||
"redis_error": s.redisError,
|
||||
"redis_backfill": s.redisBackfill,
|
||||
"redis_commit_error": s.redisCommitError,
|
||||
"mysql_fallback": s.mysqlFallback,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,847 @@
|
||||
package idempotency
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gomodule/redigo/redis"
|
||||
"longheng.io/server/internal/platform/db"
|
||||
)
|
||||
|
||||
const redisBeginScript = `
|
||||
-- Begin 原子检查 committed/pending/sequence 水位,只写 pending 占位,不提交业务结果。
|
||||
local committed_key = KEYS[1]
|
||||
local committed_seq_key = KEYS[2]
|
||||
local pending_key = KEYS[3]
|
||||
local pending_seq_key = KEYS[4]
|
||||
local ttl_ms = tonumber(ARGV[1])
|
||||
local seq = tonumber(ARGV[2]) or 0
|
||||
local fingerprint = ARGV[3] or ""
|
||||
local owner = ARGV[4] or ""
|
||||
local function metadata(value)
|
||||
local first = string.find(value or "", "\n", 1, true)
|
||||
if first == nil then return "", "" end
|
||||
local second = string.find(value, "\n", first + 1, true)
|
||||
if second == nil then return string.sub(value, first + 1), "" end
|
||||
return string.sub(value, first + 1, second - 1), string.sub(value, second + 1)
|
||||
end
|
||||
local function conflicts(value)
|
||||
local stored, _ = metadata(value)
|
||||
return stored ~= "" and fingerprint ~= "" and stored ~= fingerprint
|
||||
end
|
||||
local function marker(status)
|
||||
return status .. "\n" .. fingerprint .. "\n" .. owner
|
||||
end
|
||||
local committed = redis.call("GET", committed_key)
|
||||
if committed then
|
||||
if conflicts(committed) then return "conflict" end
|
||||
return "duplicate"
|
||||
end
|
||||
local pending_value = redis.call("GET", pending_key)
|
||||
if pending_value then
|
||||
if conflicts(pending_value) then return "conflict" end
|
||||
return "in_flight"
|
||||
end
|
||||
if seq > 0 then
|
||||
local highest = tonumber(redis.call("GET", committed_seq_key) or "0")
|
||||
if highest >= seq then
|
||||
redis.call("PSETEX", committed_key, ttl_ms, marker("replay"))
|
||||
return "replay"
|
||||
end
|
||||
while true do
|
||||
local pending = redis.call("ZREVRANGE", pending_seq_key, 0, 0, "WITHSCORES")
|
||||
if pending[1] == nil then
|
||||
break
|
||||
end
|
||||
if redis.call("EXISTS", pending[1]) == 1 then
|
||||
return "in_flight"
|
||||
end
|
||||
redis.call("ZREM", pending_seq_key, pending[1])
|
||||
end
|
||||
redis.call("ZADD", pending_seq_key, seq, pending_key)
|
||||
redis.call("PEXPIRE", pending_seq_key, ttl_ms)
|
||||
end
|
||||
redis.call("PSETEX", pending_key, ttl_ms, marker("pending"))
|
||||
return "accepted"
|
||||
`
|
||||
|
||||
const redisCommitScript = `
|
||||
-- Commit 把 pending 移入 committed,并推进 sequence 水位;之后重复请求只能走 duplicate/replay。
|
||||
local committed_key = KEYS[1]
|
||||
local committed_seq_key = KEYS[2]
|
||||
local pending_key = KEYS[3]
|
||||
local pending_seq_key = KEYS[4]
|
||||
local ttl_ms = tonumber(ARGV[1])
|
||||
local seq = tonumber(ARGV[2]) or 0
|
||||
local status = ARGV[3]
|
||||
local fingerprint = ARGV[4] or ""
|
||||
local owner = ARGV[5] or ""
|
||||
if status == "" then
|
||||
status = "accepted"
|
||||
end
|
||||
local function metadata(value)
|
||||
local first = string.find(value or "", "\n", 1, true)
|
||||
if first == nil then return "", "" end
|
||||
local second = string.find(value, "\n", first + 1, true)
|
||||
if second == nil then return string.sub(value, first + 1), "" end
|
||||
return string.sub(value, first + 1, second - 1), string.sub(value, second + 1)
|
||||
end
|
||||
local function conflicts(value)
|
||||
local stored, _ = metadata(value)
|
||||
return stored ~= "" and fingerprint ~= "" and stored ~= fingerprint
|
||||
end
|
||||
local committed = redis.call("GET", committed_key)
|
||||
if committed then
|
||||
if conflicts(committed) then return "conflict" end
|
||||
local _, committed_owner = metadata(committed)
|
||||
if committed_owner == owner then return "already_committed" end
|
||||
return "lost"
|
||||
end
|
||||
local pending = redis.call("GET", pending_key)
|
||||
if pending then
|
||||
local _, pending_owner = metadata(pending)
|
||||
if pending_owner ~= owner then return "lost" end
|
||||
if conflicts(pending) then return "conflict" end
|
||||
elseif owner ~= "" then
|
||||
return "lost"
|
||||
end
|
||||
redis.call("PSETEX", committed_key, ttl_ms, status .. "\n" .. fingerprint .. "\n" .. owner)
|
||||
if seq > 0 then
|
||||
local highest = tonumber(redis.call("GET", committed_seq_key) or "0")
|
||||
if highest < seq then
|
||||
redis.call("SET", committed_seq_key, seq, "PX", ttl_ms)
|
||||
else
|
||||
redis.call("PEXPIRE", committed_seq_key, ttl_ms)
|
||||
end
|
||||
redis.call("ZREM", pending_seq_key, pending_key)
|
||||
end
|
||||
redis.call("DEL", pending_key)
|
||||
return status
|
||||
`
|
||||
|
||||
const redisCommitResLua = `
|
||||
-- CommitResult 在一个 Lua 原子操作里提交防重状态和可重放结果,响应热缓存不参与一致性边界。
|
||||
local committed_key = KEYS[1]
|
||||
local committed_seq_key = KEYS[2]
|
||||
local pending_key = KEYS[3]
|
||||
local pending_seq_key = KEYS[4]
|
||||
local result_key = KEYS[5]
|
||||
local ttl_ms = tonumber(ARGV[1])
|
||||
local seq = tonumber(ARGV[2]) or 0
|
||||
local status = ARGV[3]
|
||||
local result = ARGV[4]
|
||||
local fingerprint = ARGV[5] or ""
|
||||
local owner = ARGV[6] or ""
|
||||
if status == "" then
|
||||
status = "accepted"
|
||||
end
|
||||
local function metadata(value)
|
||||
local first = string.find(value or "", "\n", 1, true)
|
||||
if first == nil then return "", "" end
|
||||
local second = string.find(value, "\n", first + 1, true)
|
||||
if second == nil then return string.sub(value, first + 1), "" end
|
||||
return string.sub(value, first + 1, second - 1), string.sub(value, second + 1)
|
||||
end
|
||||
local function conflicts(value)
|
||||
local stored, _ = metadata(value)
|
||||
return stored ~= "" and fingerprint ~= "" and stored ~= fingerprint
|
||||
end
|
||||
local committed = redis.call("GET", committed_key)
|
||||
if committed then
|
||||
if conflicts(committed) then return "conflict" end
|
||||
local _, committed_owner = metadata(committed)
|
||||
if committed_owner == owner and redis.call("GET", result_key) == result then return "already_committed" end
|
||||
return "lost"
|
||||
end
|
||||
local pending = redis.call("GET", pending_key)
|
||||
if pending then
|
||||
local _, pending_owner = metadata(pending)
|
||||
if pending_owner ~= owner then return "lost" end
|
||||
if conflicts(pending) then return "conflict" end
|
||||
elseif owner ~= "" then
|
||||
return "lost"
|
||||
end
|
||||
redis.call("PSETEX", committed_key, ttl_ms, status .. "\n" .. fingerprint .. "\n" .. owner)
|
||||
redis.call("PSETEX", result_key, ttl_ms, result)
|
||||
if seq > 0 then
|
||||
local highest = tonumber(redis.call("GET", committed_seq_key) or "0")
|
||||
if highest < seq then
|
||||
redis.call("SET", committed_seq_key, seq, "PX", ttl_ms)
|
||||
else
|
||||
redis.call("PEXPIRE", committed_seq_key, ttl_ms)
|
||||
end
|
||||
redis.call("ZREM", pending_seq_key, pending_key)
|
||||
end
|
||||
redis.call("DEL", pending_key)
|
||||
return status
|
||||
`
|
||||
|
||||
const redisAbortScript = `
|
||||
-- Abort 只删除 pending key 和 pending sequence 索引,不推进 committed 水位,允许失败请求重试。
|
||||
local pending_key = KEYS[1]
|
||||
local pending_seq_key = KEYS[2]
|
||||
local fingerprint = ARGV[1] or ""
|
||||
local owner = ARGV[2] or ""
|
||||
local pending = redis.call("GET", pending_key)
|
||||
if pending then
|
||||
local split = string.find(pending, "\n", 1, true)
|
||||
local stored = ""
|
||||
local stored_owner = ""
|
||||
if split ~= nil then
|
||||
local second = string.find(pending, "\n", split + 1, true)
|
||||
if second == nil then
|
||||
stored = string.sub(pending, split + 1)
|
||||
else
|
||||
stored = string.sub(pending, split + 1, second - 1)
|
||||
stored_owner = string.sub(pending, second + 1)
|
||||
end
|
||||
end
|
||||
if stored_owner ~= owner then return "lost" end
|
||||
if stored ~= "" and fingerprint ~= "" and stored ~= fingerprint then
|
||||
return "conflict"
|
||||
end
|
||||
elseif owner ~= "" then
|
||||
return "lost"
|
||||
end
|
||||
redis.call("DEL", pending_key)
|
||||
redis.call("ZREM", pending_seq_key, pending_key)
|
||||
return "aborted"
|
||||
`
|
||||
|
||||
const redisCommitLookup = `
|
||||
local key = KEYS[1]
|
||||
local seq_key = KEYS[2]
|
||||
local ttl_ms = tonumber(ARGV[1])
|
||||
local seq = tonumber(ARGV[2]) or 0
|
||||
local fingerprint = ARGV[3] or ""
|
||||
local value = redis.call("GET", key)
|
||||
if value then
|
||||
local split = string.find(value, "\n", 1, true)
|
||||
local stored = ""
|
||||
if split ~= nil then
|
||||
local second = string.find(value, "\n", split + 1, true)
|
||||
if second == nil then stored = string.sub(value, split + 1) else stored = string.sub(value, split + 1, second - 1) end
|
||||
end
|
||||
if stored ~= "" and fingerprint ~= "" and stored ~= fingerprint then
|
||||
return "conflict"
|
||||
end
|
||||
return "duplicate"
|
||||
end
|
||||
if seq > 0 then
|
||||
local highest = tonumber(redis.call("GET", seq_key) or "0")
|
||||
if highest >= seq then
|
||||
local source_ttl = tonumber(redis.call("PTTL", seq_key) or "-1")
|
||||
local replay_ttl = ttl_ms
|
||||
if source_ttl > 0 and source_ttl < replay_ttl then replay_ttl = source_ttl end
|
||||
if replay_ttl > 0 then redis.call("PSETEX", key, replay_ttl, "replay\n" .. fingerprint .. "\n") end
|
||||
return "replay"
|
||||
end
|
||||
end
|
||||
return "miss"
|
||||
`
|
||||
|
||||
const redisCommitStore = `
|
||||
local key = KEYS[1]
|
||||
local seq_key = KEYS[2]
|
||||
local ttl_ms = tonumber(ARGV[1])
|
||||
local seq = tonumber(ARGV[2]) or 0
|
||||
local status = ARGV[3]
|
||||
local fingerprint = ARGV[4] or ""
|
||||
local owner = ARGV[5] or ""
|
||||
if status == "" then
|
||||
status = "accepted"
|
||||
end
|
||||
local current = redis.call("GET", key)
|
||||
if current then
|
||||
local split = string.find(current, "\n", 1, true)
|
||||
local stored = ""
|
||||
if split ~= nil then
|
||||
local second = string.find(current, "\n", split + 1, true)
|
||||
if second == nil then stored = string.sub(current, split + 1) else stored = string.sub(current, split + 1, second - 1) end
|
||||
end
|
||||
if stored ~= "" and fingerprint ~= "" and stored ~= fingerprint then
|
||||
return "conflict"
|
||||
end
|
||||
end
|
||||
redis.call("PSETEX", key, ttl_ms, status .. "\n" .. fingerprint .. "\n" .. owner)
|
||||
if seq > 0 then
|
||||
local highest = tonumber(redis.call("GET", seq_key) or "0")
|
||||
if highest < seq then
|
||||
redis.call("SET", seq_key, seq, "PX", ttl_ms)
|
||||
else
|
||||
redis.call("PEXPIRE", seq_key, ttl_ms)
|
||||
end
|
||||
end
|
||||
return status
|
||||
`
|
||||
|
||||
const redisCommitRecover = `
|
||||
local key = KEYS[1]
|
||||
local seq_key = KEYS[2]
|
||||
local seq = tonumber(ARGV[1]) or 0
|
||||
local fingerprint = ARGV[2] or ""
|
||||
local owner = ARGV[3] or ""
|
||||
local value = redis.call("GET", key)
|
||||
if value then
|
||||
local split = string.find(value, "\n", 1, true)
|
||||
local stored = ""
|
||||
local stored_owner = ""
|
||||
if split ~= nil then
|
||||
local second = string.find(value, "\n", split + 1, true)
|
||||
if second == nil then stored = string.sub(value, split + 1) else
|
||||
stored = string.sub(value, split + 1, second - 1)
|
||||
stored_owner = string.sub(value, second + 1)
|
||||
end
|
||||
end
|
||||
if stored ~= "" and fingerprint ~= "" and stored ~= fingerprint then
|
||||
return "conflict"
|
||||
end
|
||||
if stored_owner == owner then return "hit" end
|
||||
return "lost"
|
||||
end
|
||||
if owner == "" and seq > 0 then
|
||||
local highest = tonumber(redis.call("GET", seq_key) or "0")
|
||||
if highest >= seq then
|
||||
return "hit"
|
||||
end
|
||||
end
|
||||
return "miss"
|
||||
`
|
||||
|
||||
const redisResultLookup = `
|
||||
return redis.call("GET", KEYS[1])
|
||||
`
|
||||
|
||||
const redisResultRecover = `
|
||||
if redis.call("EXISTS", KEYS[1]) ~= 1 then
|
||||
return "miss"
|
||||
end
|
||||
local marker = redis.call("GET", KEYS[1])
|
||||
local first = string.find(marker or "", "\n", 1, true)
|
||||
local stored_owner = ""
|
||||
if first ~= nil then
|
||||
local second = string.find(marker, "\n", first + 1, true)
|
||||
if second ~= nil then stored_owner = string.sub(marker, second + 1) end
|
||||
end
|
||||
if stored_owner ~= ARGV[2] then return "miss" end
|
||||
local result = redis.call("GET", KEYS[2])
|
||||
if result == ARGV[1] then
|
||||
return "hit"
|
||||
end
|
||||
return "miss"
|
||||
`
|
||||
|
||||
type RedisOptions struct {
|
||||
Address string
|
||||
Password string
|
||||
DB int
|
||||
KeyPrefix string
|
||||
PoolSize int
|
||||
Timeout time.Duration
|
||||
ConnectTimeout time.Duration
|
||||
ReadTimeout time.Duration
|
||||
WriteTimeout time.Duration
|
||||
TTL time.Duration
|
||||
Now func() time.Time
|
||||
Executor db.RedisExecutor
|
||||
}
|
||||
|
||||
type redisStore struct {
|
||||
keyPrefix string
|
||||
ttl time.Duration
|
||||
executor db.RedisExecutor
|
||||
closer interface{ Close() error }
|
||||
}
|
||||
|
||||
func NewRedis(options RedisOptions) *Guard {
|
||||
options = normRedisOpts(options)
|
||||
return New(Options{
|
||||
TTL: options.TTL,
|
||||
Now: options.Now,
|
||||
Kind: "redis",
|
||||
Store: newRedisOpts(options),
|
||||
})
|
||||
}
|
||||
|
||||
func newRedisOpts(options RedisOptions) *redisStore {
|
||||
options = normRedisOpts(options)
|
||||
executor := options.Executor
|
||||
var closer interface{ Close() error }
|
||||
if executor == nil {
|
||||
redigo := db.NewRedigoExecutor(db.RedigoOptions{
|
||||
Address: options.Address,
|
||||
Password: options.Password,
|
||||
DB: options.DB,
|
||||
PoolSize: options.PoolSize,
|
||||
Timeout: options.Timeout,
|
||||
ConnectTimeout: options.ConnectTimeout,
|
||||
ReadTimeout: options.ReadTimeout,
|
||||
WriteTimeout: options.WriteTimeout,
|
||||
})
|
||||
executor = redigo
|
||||
closer = redigo
|
||||
} else if candidate, ok := executor.(interface{ Close() error }); ok {
|
||||
closer = candidate
|
||||
}
|
||||
return newRedisStore(options.KeyPrefix, options.TTL, executor, closer)
|
||||
}
|
||||
|
||||
func newRedisStore(keyPrefix string, ttl time.Duration, executor db.RedisExecutor, closer interface{ Close() error }) *redisStore {
|
||||
if ttl <= 0 {
|
||||
ttl = 10 * time.Minute
|
||||
}
|
||||
keyPrefix = strings.TrimSpace(keyPrefix)
|
||||
if keyPrefix == "" {
|
||||
keyPrefix = "idempotency"
|
||||
}
|
||||
return &redisStore{
|
||||
keyPrefix: keyPrefix,
|
||||
ttl: ttl,
|
||||
executor: executor,
|
||||
closer: closer,
|
||||
}
|
||||
}
|
||||
|
||||
func normRedisOpts(options RedisOptions) RedisOptions {
|
||||
options.Address = strings.TrimSpace(options.Address)
|
||||
if options.Address == "" {
|
||||
options.Address = "127.0.0.1:6379"
|
||||
}
|
||||
options.KeyPrefix = strings.TrimSpace(options.KeyPrefix)
|
||||
if options.KeyPrefix == "" {
|
||||
options.KeyPrefix = "idempotency"
|
||||
}
|
||||
if options.PoolSize <= 0 {
|
||||
options.PoolSize = 8
|
||||
}
|
||||
if options.Timeout <= 0 {
|
||||
options.Timeout = 2 * time.Second
|
||||
}
|
||||
if options.ConnectTimeout <= 0 {
|
||||
options.ConnectTimeout = options.Timeout
|
||||
}
|
||||
if options.ReadTimeout <= 0 {
|
||||
options.ReadTimeout = options.Timeout
|
||||
}
|
||||
if options.WriteTimeout <= 0 {
|
||||
options.WriteTimeout = options.Timeout
|
||||
}
|
||||
if options.TTL <= 0 {
|
||||
options.TTL = 10 * time.Minute
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
func (s *redisStore) Check(ctx context.Context, item Request) (Decision, error) {
|
||||
decision, err := s.Begin(ctx, item)
|
||||
if err != nil {
|
||||
return Decision{}, err
|
||||
}
|
||||
if decision.Status == StatusAccepted {
|
||||
if err := s.Commit(ctx, item, decision); err != nil {
|
||||
return Decision{}, err
|
||||
}
|
||||
}
|
||||
return decision, nil
|
||||
}
|
||||
|
||||
func (s *redisStore) Begin(ctx context.Context, item Request) (Decision, error) {
|
||||
if s == nil || s.executor == nil {
|
||||
return Decision{}, fmt.Errorf("redis idempotency executor is nil")
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return Decision{}, err
|
||||
}
|
||||
key := item.Key
|
||||
if key == "" {
|
||||
key = DerivedKey(item.Scope, item.Subject, item.Session, item.Sequence)
|
||||
}
|
||||
// Redis Begin 全部交给 Lua,避免多 logic 节点同时接受同一 key 或同一 sequence。
|
||||
statusText, err := redis.String(s.executor.Do(
|
||||
ctx,
|
||||
"EVAL",
|
||||
redisBeginScript,
|
||||
4,
|
||||
s.requestKey(key),
|
||||
s.sequenceKey(sequenceScope(item.Scope, item.Subject, item.Session)),
|
||||
s.pendingRequestKey(key),
|
||||
s.pendingSequenceKey(sequenceScope(item.Scope, item.Subject, item.Session)),
|
||||
int64(s.ttlOrDefault()/time.Millisecond),
|
||||
item.Sequence,
|
||||
item.Fingerprint,
|
||||
item.reservationToken,
|
||||
))
|
||||
if err != nil {
|
||||
return Decision{}, err
|
||||
}
|
||||
if statusText == "conflict" {
|
||||
return Decision{}, ErrRequestConflict
|
||||
}
|
||||
status := Status(statusText)
|
||||
if status != StatusDuplicate && status != StatusInFlight && status != StatusReplay {
|
||||
status = StatusAccepted
|
||||
}
|
||||
decision := Decision{Status: status, Key: key, Sequence: item.Sequence}
|
||||
if status == StatusAccepted {
|
||||
decision.ownerToken = item.reservationToken
|
||||
}
|
||||
return decision, nil
|
||||
}
|
||||
|
||||
func (s *redisStore) Commit(ctx context.Context, item Request, decision Decision) error {
|
||||
if s == nil || s.executor == nil {
|
||||
return fmt.Errorf("redis idempotency executor is nil")
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
key := decision.Key
|
||||
if key == "" {
|
||||
key = item.Key
|
||||
}
|
||||
if key == "" {
|
||||
key = DerivedKey(item.Scope, item.Subject, item.Session, item.Sequence)
|
||||
}
|
||||
status := decision.Status
|
||||
if status == "" {
|
||||
status = StatusAccepted
|
||||
}
|
||||
// Commit 的 Lua 同步删除 pending 并写 committed;调用方必须在业务 handler 成功后再调用。
|
||||
statusText, err := redis.String(s.executor.Do(
|
||||
ctx,
|
||||
"EVAL",
|
||||
redisCommitScript,
|
||||
4,
|
||||
s.requestKey(key),
|
||||
s.sequenceKey(sequenceScope(item.Scope, item.Subject, item.Session)),
|
||||
s.pendingRequestKey(key),
|
||||
s.pendingSequenceKey(sequenceScope(item.Scope, item.Subject, item.Session)),
|
||||
int64(s.ttlOrDefault()/time.Millisecond),
|
||||
item.Sequence,
|
||||
string(status),
|
||||
item.Fingerprint,
|
||||
decision.ownerToken,
|
||||
))
|
||||
if err == nil && statusText == "conflict" {
|
||||
return ErrRequestConflict
|
||||
}
|
||||
if err == nil && statusText == "lost" {
|
||||
return ErrReservationLost
|
||||
}
|
||||
if err != nil && s.redisCommitRecovered(ctx, item, key, decision.ownerToken) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *redisStore) CommitResult(ctx context.Context, item Request, decision Decision, payload []byte) error {
|
||||
if s == nil || s.executor == nil {
|
||||
return fmt.Errorf("redis idempotency executor is nil")
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
key := decision.Key
|
||||
if key == "" {
|
||||
key = item.Key
|
||||
}
|
||||
if key == "" {
|
||||
key = DerivedKey(item.Scope, item.Subject, item.Session, item.Sequence)
|
||||
}
|
||||
status := decision.Status
|
||||
if status == "" {
|
||||
status = StatusAccepted
|
||||
}
|
||||
statusText, err := redis.String(s.executor.Do(
|
||||
ctx,
|
||||
"EVAL",
|
||||
redisCommitResLua,
|
||||
5,
|
||||
s.requestKey(key),
|
||||
s.sequenceKey(sequenceScope(item.Scope, item.Subject, item.Session)),
|
||||
s.pendingRequestKey(key),
|
||||
s.pendingSequenceKey(sequenceScope(item.Scope, item.Subject, item.Session)),
|
||||
s.resultKey(key),
|
||||
int64(s.ttlOrDefault()/time.Millisecond),
|
||||
item.Sequence,
|
||||
string(status),
|
||||
payload,
|
||||
item.Fingerprint,
|
||||
decision.ownerToken,
|
||||
))
|
||||
if err == nil && statusText == "conflict" {
|
||||
return ErrRequestConflict
|
||||
}
|
||||
if err == nil && statusText == "lost" {
|
||||
return ErrReservationLost
|
||||
}
|
||||
if err != nil && s.redisResultRecovered(ctx, key, payload, decision.ownerToken) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *redisStore) LookupResult(ctx context.Context, decision Decision) ([]byte, bool, error) {
|
||||
if s == nil || s.executor == nil {
|
||||
return nil, false, fmt.Errorf("redis idempotency executor is nil")
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
key := strings.TrimSpace(decision.Key)
|
||||
if key == "" {
|
||||
return nil, false, nil
|
||||
}
|
||||
payload, err := redis.Bytes(s.executor.Do(ctx, "EVAL", redisResultLookup, 1, s.resultKey(key)))
|
||||
if errors.Is(err, redis.ErrNil) {
|
||||
return nil, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return append([]byte(nil), payload...), true, nil
|
||||
}
|
||||
|
||||
func (s *redisStore) redisResultRecovered(parent context.Context, key string, payload []byte, ownerToken string) bool {
|
||||
ctx, cancel := idemRecoverCtx(parent)
|
||||
defer cancel()
|
||||
status, err := redis.String(s.executor.Do(
|
||||
ctx,
|
||||
"EVAL",
|
||||
redisResultRecover,
|
||||
2,
|
||||
s.requestKey(key),
|
||||
s.resultKey(key),
|
||||
payload,
|
||||
ownerToken,
|
||||
))
|
||||
return err == nil && status == "hit"
|
||||
}
|
||||
|
||||
func (s *redisStore) redisCommitRecovered(parent context.Context, item Request, key, ownerToken string) bool {
|
||||
ctx, cancel := idemRecoverCtx(parent)
|
||||
defer cancel()
|
||||
status, err := redis.String(s.executor.Do(
|
||||
ctx,
|
||||
"EVAL",
|
||||
redisCommitRecover,
|
||||
2,
|
||||
s.requestKey(key),
|
||||
s.sequenceKey(sequenceScope(item.Scope, item.Subject, item.Session)),
|
||||
item.Sequence,
|
||||
item.Fingerprint,
|
||||
ownerToken,
|
||||
))
|
||||
return err == nil && status == "hit"
|
||||
}
|
||||
|
||||
func (s *redisStore) Abort(ctx context.Context, item Request, decision Decision) error {
|
||||
if s == nil || s.executor == nil {
|
||||
return fmt.Errorf("redis idempotency executor is nil")
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
key := decision.Key
|
||||
if key == "" {
|
||||
key = item.Key
|
||||
}
|
||||
if key == "" {
|
||||
key = DerivedKey(item.Scope, item.Subject, item.Session, item.Sequence)
|
||||
}
|
||||
// Abort 用于 handler 失败,避免 Redis pending 卡住后续重试。
|
||||
statusText, err := redis.String(s.executor.Do(
|
||||
ctx,
|
||||
"EVAL",
|
||||
redisAbortScript,
|
||||
2,
|
||||
s.pendingRequestKey(key),
|
||||
s.pendingSequenceKey(sequenceScope(item.Scope, item.Subject, item.Session)),
|
||||
item.Fingerprint,
|
||||
decision.ownerToken,
|
||||
))
|
||||
if err == nil && statusText == "conflict" {
|
||||
return ErrRequestConflict
|
||||
}
|
||||
if err == nil && statusText == "lost" {
|
||||
return ErrReservationLost
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *redisStore) LookupCommitted(ctx context.Context, item Request) (Decision, bool, error) {
|
||||
if s == nil || s.executor == nil {
|
||||
return Decision{}, false, fmt.Errorf("redis idempotency executor is nil")
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return Decision{}, false, err
|
||||
}
|
||||
key := item.Key
|
||||
if key == "" {
|
||||
key = DerivedKey(item.Scope, item.Subject, item.Session, item.Sequence)
|
||||
}
|
||||
statusText, err := redis.String(s.executor.Do(
|
||||
ctx,
|
||||
"EVAL",
|
||||
redisCommitLookup,
|
||||
2,
|
||||
s.committedRequestKey(key),
|
||||
s.committedSequenceKey(sequenceScope(item.Scope, item.Subject, item.Session)),
|
||||
int64(s.ttlOrDefault()/time.Millisecond),
|
||||
item.Sequence,
|
||||
item.Fingerprint,
|
||||
))
|
||||
if err != nil {
|
||||
return Decision{}, false, err
|
||||
}
|
||||
switch Status(statusText) {
|
||||
case StatusDuplicate:
|
||||
return Decision{Status: StatusDuplicate, Key: key, Sequence: item.Sequence}, true, nil
|
||||
case StatusReplay:
|
||||
return Decision{Status: StatusReplay, Key: key, Sequence: item.Sequence}, true, nil
|
||||
default:
|
||||
if statusText == "conflict" {
|
||||
return Decision{}, false, ErrRequestConflict
|
||||
}
|
||||
return Decision{}, false, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (s *redisStore) StoreCommitted(ctx context.Context, item Request, decision Decision) error {
|
||||
return s.storeCommittedTTL(ctx, item, decision, s.ttlOrDefault())
|
||||
}
|
||||
|
||||
func (s *redisStore) storeCommittedTTL(ctx context.Context, item Request, decision Decision, ttl time.Duration) error {
|
||||
if s == nil || s.executor == nil {
|
||||
return fmt.Errorf("redis idempotency executor is nil")
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if ttl < time.Millisecond {
|
||||
return nil
|
||||
}
|
||||
key := decision.Key
|
||||
if key == "" {
|
||||
key = item.Key
|
||||
}
|
||||
if key == "" {
|
||||
key = DerivedKey(item.Scope, item.Subject, item.Session, item.Sequence)
|
||||
}
|
||||
status := decision.Status
|
||||
if status == "" {
|
||||
status = StatusAccepted
|
||||
}
|
||||
statusText, err := redis.String(s.executor.Do(
|
||||
ctx,
|
||||
"EVAL",
|
||||
redisCommitStore,
|
||||
2,
|
||||
s.committedRequestKey(key),
|
||||
s.committedSequenceKey(sequenceScope(item.Scope, item.Subject, item.Session)),
|
||||
int64(ttl/time.Millisecond),
|
||||
item.Sequence,
|
||||
string(status),
|
||||
item.Fingerprint,
|
||||
decision.ownerToken,
|
||||
))
|
||||
if err == nil && statusText == "conflict" {
|
||||
return ErrRequestConflict
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *redisStore) Snapshot() map[string]any {
|
||||
if s == nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
return map[string]any{
|
||||
"backend": "redis",
|
||||
"ttl_seconds": int64(s.ttlOrDefault() / time.Second),
|
||||
"key_prefix": s.keyPrefixOrDefault(),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *redisStore) Close(context.Context) error {
|
||||
if s == nil || s.closer == nil {
|
||||
return nil
|
||||
}
|
||||
return s.closer.Close()
|
||||
}
|
||||
|
||||
func (s *redisStore) requestKey(key string) string {
|
||||
return s.keyPrefixOrDefault() + ":key:" + digestKey(key)
|
||||
}
|
||||
|
||||
func (s *redisStore) sequenceKey(scope string) string {
|
||||
return s.keyPrefixOrDefault() + ":seq:" + digestKey(scope)
|
||||
}
|
||||
|
||||
func (s *redisStore) committedRequestKey(key string) string {
|
||||
return s.keyPrefixOrDefault() + ":committed:key:" + digestKey(key)
|
||||
}
|
||||
|
||||
func (s *redisStore) committedSequenceKey(scope string) string {
|
||||
return s.keyPrefixOrDefault() + ":committed:seq:" + digestKey(scope)
|
||||
}
|
||||
|
||||
func (s *redisStore) pendingRequestKey(key string) string {
|
||||
return s.keyPrefixOrDefault() + ":pending:key:" + digestKey(key)
|
||||
}
|
||||
|
||||
func (s *redisStore) pendingSequenceKey(scope string) string {
|
||||
return s.keyPrefixOrDefault() + ":pending:seq:" + digestKey(scope)
|
||||
}
|
||||
|
||||
func (s *redisStore) resultKey(key string) string {
|
||||
return s.keyPrefixOrDefault() + ":result:key:" + digestKey(key)
|
||||
}
|
||||
|
||||
func (s *redisStore) ttlOrDefault() time.Duration {
|
||||
if s == nil || s.ttl <= 0 {
|
||||
return 10 * time.Minute
|
||||
}
|
||||
return s.ttl
|
||||
}
|
||||
|
||||
func (s *redisStore) keyPrefixOrDefault() string {
|
||||
if s == nil {
|
||||
return "idempotency"
|
||||
}
|
||||
prefix := strings.TrimSpace(s.keyPrefix)
|
||||
if prefix == "" {
|
||||
return "idempotency"
|
||||
}
|
||||
return prefix
|
||||
}
|
||||
|
||||
func digestKey(value string) string {
|
||||
sum := sha256.Sum256([]byte(value))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
Reference in new issue
Block a user