DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
hanfuit committed 2026-11-04 14:31:55 +08:00
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[:])
}