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,347 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type asyncDeadSinkOpts[T any, F comparable] struct {
|
||||
Limit int
|
||||
Store DeadLetterStore[T, F]
|
||||
StoreTimeout time.Duration
|
||||
RecordKey KeyFunc[T]
|
||||
NormalizeKey func(string) string
|
||||
Clone CloneFunc[T]
|
||||
NormalizeFields func([]F) []F
|
||||
}
|
||||
|
||||
type asyncDeadLetterSink[T any, F comparable] struct {
|
||||
mu sync.Mutex
|
||||
storeMu sync.Mutex
|
||||
limit int
|
||||
store DeadLetterStore[T, F]
|
||||
storeTimeout time.Duration
|
||||
storeError string
|
||||
deadLetters map[string]AsyncStoreDeadLetter[T, F]
|
||||
deadOrder []string
|
||||
recordKey KeyFunc[T]
|
||||
normalizeKey func(string) string
|
||||
clone CloneFunc[T]
|
||||
normalizeFields func([]F) []F
|
||||
}
|
||||
|
||||
func newAsyncDeadSink[T any, F comparable](options asyncDeadSinkOpts[T, F]) *asyncDeadLetterSink[T, F] {
|
||||
if options.Clone == nil {
|
||||
options.Clone = IdentityClone[T]
|
||||
}
|
||||
if options.StoreTimeout <= 0 {
|
||||
options.StoreTimeout = defAsyncFlush
|
||||
}
|
||||
return &asyncDeadLetterSink[T, F]{
|
||||
limit: options.Limit,
|
||||
store: options.Store,
|
||||
storeTimeout: options.StoreTimeout,
|
||||
deadLetters: make(map[string]AsyncStoreDeadLetter[T, F]),
|
||||
recordKey: options.RecordKey,
|
||||
normalizeKey: options.NormalizeKey,
|
||||
clone: options.Clone,
|
||||
normalizeFields: options.NormalizeFields,
|
||||
}
|
||||
}
|
||||
|
||||
// Load 从持久化死信存储恢复死信记录。
|
||||
func (s *asyncDeadLetterSink[T, F]) Load() {
|
||||
if s.store == nil {
|
||||
return
|
||||
}
|
||||
s.storeMu.Lock()
|
||||
defer s.storeMu.Unlock()
|
||||
ctx, cancel := s.storeContext()
|
||||
deadLetters, err := s.store.List(ctx)
|
||||
cancel()
|
||||
if err != nil {
|
||||
s.setStoreError(err)
|
||||
return
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
for _, dead := range deadLetters {
|
||||
dead = s.cloneDeadLetter(dead)
|
||||
dead.AccountID = s.NormalizeRecordKey(dead.AccountID)
|
||||
if dead.AccountID == "" {
|
||||
accountID, err := s.RecordKey(dead.Profile)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
dead.AccountID = accountID
|
||||
}
|
||||
if dead.AccountID == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := s.deadLetters[dead.AccountID]; !exists {
|
||||
s.deadOrder = append(s.deadOrder, dead.AccountID)
|
||||
}
|
||||
s.deadLetters[dead.AccountID] = dead
|
||||
}
|
||||
evicted := s.enforceLimitLocked()
|
||||
s.mu.Unlock()
|
||||
s.deletePersistedMany(evicted)
|
||||
}
|
||||
|
||||
// Get 返回指定账号的死信副本。
|
||||
func (s *asyncDeadLetterSink[T, F]) Get(accountID string) (AsyncStoreDeadLetter[T, F], bool) {
|
||||
accountID = s.NormalizeRecordKey(accountID)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
dead, ok := s.deadLetters[accountID]
|
||||
if !ok {
|
||||
return AsyncStoreDeadLetter[T, F]{}, false
|
||||
}
|
||||
return s.cloneDeadLetter(dead), true
|
||||
}
|
||||
|
||||
// Add 写入一条死信并持久化;持久化失败时回滚内存死信,调用方应继续保留 pending。
|
||||
func (s *asyncDeadLetterSink[T, F]) Add(save pendingSave[T, F]) error {
|
||||
accountID, err := s.RecordKey(save.profile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dead := AsyncStoreDeadLetter[T, F]{
|
||||
AccountID: accountID,
|
||||
Profile: s.clone(save.profile),
|
||||
Fields: s.NormalizeFields(save.fields),
|
||||
Attempts: save.attempts,
|
||||
Error: save.lastError,
|
||||
FirstFailedAt: save.firstFailedAt,
|
||||
LastFailedAt: save.lastFailedAt,
|
||||
}
|
||||
|
||||
s.storeMu.Lock()
|
||||
defer s.storeMu.Unlock()
|
||||
s.mu.Lock()
|
||||
previous, existed := s.deadLetters[accountID]
|
||||
if _, exists := s.deadLetters[accountID]; !exists {
|
||||
s.deadOrder = append(s.deadOrder, accountID)
|
||||
}
|
||||
s.deadLetters[accountID] = dead
|
||||
s.mu.Unlock()
|
||||
|
||||
if err := s.persist(dead); err != nil {
|
||||
s.rollbackAdd(accountID, previous, existed)
|
||||
return err
|
||||
}
|
||||
s.mu.Lock()
|
||||
evicted := s.enforceLimitLocked()
|
||||
s.mu.Unlock()
|
||||
s.deletePersistedMany(evicted)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Take 取出并删除指定账号死信。
|
||||
func (s *asyncDeadLetterSink[T, F]) Take(accountID string) (AsyncStoreDeadLetter[T, F], bool) {
|
||||
accountID = s.NormalizeRecordKey(accountID)
|
||||
s.storeMu.Lock()
|
||||
defer s.storeMu.Unlock()
|
||||
s.mu.Lock()
|
||||
|
||||
dead, ok := s.deadLetters[accountID]
|
||||
if !ok {
|
||||
s.mu.Unlock()
|
||||
return AsyncStoreDeadLetter[T, F]{}, false
|
||||
}
|
||||
delete(s.deadLetters, accountID)
|
||||
s.removeOrderLocked(accountID)
|
||||
out := s.cloneDeadLetter(dead)
|
||||
s.mu.Unlock()
|
||||
s.deletePersisted(accountID)
|
||||
return out, true
|
||||
}
|
||||
|
||||
func (s *asyncDeadLetterSink[T, F]) restoreMemory(dead AsyncStoreDeadLetter[T, F]) {
|
||||
dead = s.cloneDeadLetter(dead)
|
||||
dead.AccountID = s.NormalizeRecordKey(dead.AccountID)
|
||||
if dead.AccountID == "" {
|
||||
accountID, err := s.RecordKey(dead.Profile)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
dead.AccountID = accountID
|
||||
}
|
||||
if dead.AccountID == "" {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
if _, exists := s.deadLetters[dead.AccountID]; !exists {
|
||||
s.deadOrder = append(s.deadOrder, dead.AccountID)
|
||||
}
|
||||
s.deadLetters[dead.AccountID] = dead
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// List 返回按失败时间排序的死信快照。
|
||||
func (s *asyncDeadLetterSink[T, F]) List() []AsyncStoreDeadLetter[T, F] {
|
||||
s.mu.Lock()
|
||||
out := make([]AsyncStoreDeadLetter[T, F], 0, len(s.deadLetters))
|
||||
for _, dead := range s.deadLetters {
|
||||
out = append(out, s.cloneDeadLetter(dead))
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].LastFailedAt.Equal(out[j].LastFailedAt) {
|
||||
return out[i].AccountID < out[j].AccountID
|
||||
}
|
||||
return out[i].LastFailedAt.Before(out[j].LastFailedAt)
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// Count 返回当前死信数量。
|
||||
func (s *asyncDeadLetterSink[T, F]) Count() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return len(s.deadLetters)
|
||||
}
|
||||
|
||||
// Limit 返回死信内存上限。
|
||||
func (s *asyncDeadLetterSink[T, F]) Limit() int {
|
||||
return s.limit
|
||||
}
|
||||
|
||||
// StoreName 返回持久化死信存储类型名。
|
||||
func (s *asyncDeadLetterSink[T, F]) StoreName() string {
|
||||
if s.store == nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%T", s.store)
|
||||
}
|
||||
|
||||
// LastError 返回最近一次死信持久化错误。
|
||||
func (s *asyncDeadLetterSink[T, F]) LastError() string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.storeError
|
||||
}
|
||||
|
||||
// CheckStore 检查死信存储可用性。
|
||||
func (s *asyncDeadLetterSink[T, F]) CheckStore(ctx context.Context) error {
|
||||
return Check(ctx, s.store)
|
||||
}
|
||||
|
||||
// RecordKey 计算并归一化死信记录主键。
|
||||
func (s *asyncDeadLetterSink[T, F]) RecordKey(record T) (string, error) {
|
||||
key, err := RecordKey(s.recordKey, record)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return s.NormalizeRecordKey(key), nil
|
||||
}
|
||||
|
||||
// NormalizeRecordKey 归一化死信主键。
|
||||
func (s *asyncDeadLetterSink[T, F]) NormalizeRecordKey(key string) string {
|
||||
if s.normalizeKey == nil {
|
||||
return key
|
||||
}
|
||||
return s.normalizeKey(key)
|
||||
}
|
||||
|
||||
// NormalizeFields 归一化死信字段列表。
|
||||
func (s *asyncDeadLetterSink[T, F]) NormalizeFields(fields []F) []F {
|
||||
if s.normalizeFields == nil {
|
||||
return append([]F(nil), fields...)
|
||||
}
|
||||
return s.normalizeFields(fields)
|
||||
}
|
||||
|
||||
func (s *asyncDeadLetterSink[T, F]) cloneDeadLetter(dead AsyncStoreDeadLetter[T, F]) AsyncStoreDeadLetter[T, F] {
|
||||
dead.Profile = s.clone(dead.Profile)
|
||||
dead.Fields = s.NormalizeFields(dead.Fields)
|
||||
return dead
|
||||
}
|
||||
|
||||
func (s *asyncDeadLetterSink[T, F]) enforceLimitLocked() []string {
|
||||
var evicted []string
|
||||
for s.limit > 0 && len(s.deadLetters) > s.limit && len(s.deadOrder) > 0 {
|
||||
evict := s.deadOrder[0]
|
||||
s.deadOrder = s.deadOrder[1:]
|
||||
delete(s.deadLetters, evict)
|
||||
evicted = append(evicted, evict)
|
||||
}
|
||||
return evicted
|
||||
}
|
||||
|
||||
func (s *asyncDeadLetterSink[T, F]) removeOrderLocked(accountID string) {
|
||||
for i, id := range s.deadOrder {
|
||||
if id == accountID {
|
||||
s.deadOrder = append(s.deadOrder[:i], s.deadOrder[i+1:]...)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *asyncDeadLetterSink[T, F]) persist(dead AsyncStoreDeadLetter[T, F]) error {
|
||||
if s.store == nil {
|
||||
return nil
|
||||
}
|
||||
ctx, cancel := s.storeContext()
|
||||
err := s.store.Save(ctx, s.cloneDeadLetter(dead))
|
||||
cancel()
|
||||
if err != nil {
|
||||
s.setStoreError(err)
|
||||
return err
|
||||
}
|
||||
s.setStoreError(nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *asyncDeadLetterSink[T, F]) deletePersisted(accountID string) {
|
||||
if s.store == nil {
|
||||
return
|
||||
}
|
||||
ctx, cancel := s.storeContext()
|
||||
err := s.store.Delete(ctx, accountID)
|
||||
cancel()
|
||||
if err != nil {
|
||||
s.setStoreError(err)
|
||||
return
|
||||
}
|
||||
s.setStoreError(nil)
|
||||
}
|
||||
|
||||
func (s *asyncDeadLetterSink[T, F]) deletePersistedMany(accountIDs []string) {
|
||||
for _, accountID := range accountIDs {
|
||||
s.deletePersisted(accountID)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *asyncDeadLetterSink[T, F]) storeContext() (context.Context, context.CancelFunc) {
|
||||
timeout := defAsyncFlush
|
||||
if s != nil && s.storeTimeout > 0 {
|
||||
timeout = s.storeTimeout
|
||||
}
|
||||
return context.WithTimeout(context.Background(), timeout)
|
||||
}
|
||||
|
||||
func (s *asyncDeadLetterSink[T, F]) rollbackAdd(accountID string, previous AsyncStoreDeadLetter[T, F], existed bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if existed {
|
||||
s.deadLetters[accountID] = previous
|
||||
return
|
||||
}
|
||||
delete(s.deadLetters, accountID)
|
||||
s.removeOrderLocked(accountID)
|
||||
}
|
||||
|
||||
func (s *asyncDeadLetterSink[T, F]) setStoreError(err error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if err == nil {
|
||||
s.storeError = ""
|
||||
return
|
||||
}
|
||||
s.storeError = err.Error()
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type asyncFlusherOptions[T any, F comparable] struct {
|
||||
Base Store[T]
|
||||
Queue *asyncPendingQueue[T, F]
|
||||
Retry asyncRetryPolicy[T, F]
|
||||
DeadLetters *asyncDeadLetterSink[T, F]
|
||||
Clone CloneFunc[T]
|
||||
NormalizeFields func([]F) []F
|
||||
SaveFields SaveFieldsFunc[T, F]
|
||||
SaveBatch SaveFieldBatchFunc[T, F]
|
||||
AutoExpireTTL time.Duration
|
||||
Expire ExpireFunc[T]
|
||||
}
|
||||
|
||||
type asyncFlusher[T any, F comparable] struct {
|
||||
mu sync.Mutex
|
||||
base Store[T]
|
||||
queue *asyncPendingQueue[T, F]
|
||||
retry asyncRetryPolicy[T, F]
|
||||
deadLetters *asyncDeadLetterSink[T, F]
|
||||
clone CloneFunc[T]
|
||||
normalizeFields func([]F) []F
|
||||
saveFields SaveFieldsFunc[T, F]
|
||||
saveBatch SaveFieldBatchFunc[T, F]
|
||||
autoExpireTTL time.Duration
|
||||
expire ExpireFunc[T]
|
||||
}
|
||||
|
||||
func newAsyncFlusher[T any, F comparable](options asyncFlusherOptions[T, F]) *asyncFlusher[T, F] {
|
||||
if options.Clone == nil {
|
||||
options.Clone = IdentityClone[T]
|
||||
}
|
||||
return &asyncFlusher[T, F]{
|
||||
base: options.Base,
|
||||
queue: options.Queue,
|
||||
retry: options.Retry,
|
||||
deadLetters: options.DeadLetters,
|
||||
clone: options.Clone,
|
||||
normalizeFields: options.NormalizeFields,
|
||||
saveFields: options.SaveFields,
|
||||
saveBatch: options.SaveBatch,
|
||||
autoExpireTTL: options.AutoExpireTTL,
|
||||
expire: options.Expire,
|
||||
}
|
||||
}
|
||||
|
||||
// Flush 刷出 pending 队列,并把失败项转入重试或死信。
|
||||
func (f *asyncFlusher[T, F]) Flush(ctx context.Context, force bool) error {
|
||||
ctx = contextOrBackground(ctx)
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
||||
for {
|
||||
batch := f.queue.Take(force, time.Now().UTC())
|
||||
if len(batch) == 0 {
|
||||
return nil
|
||||
}
|
||||
// Take 会乐观地从 pending 队列移除记录。下面每条失败路径都必须记录失败项
|
||||
// 供重试/死信处理,或把尚未尝试的项放回队列;否则后端抖动会静默丢掉已接受的玩家状态。
|
||||
if f.saveBatch != nil {
|
||||
if err := ctx.Err(); err != nil {
|
||||
f.queue.Restore(batch)
|
||||
return err
|
||||
}
|
||||
if err := f.saveBatch(ctx, f.base, fieldSavesPending(batch, f.clone, f.normalizeFields)); err != nil {
|
||||
errs := make([]error, 0, len(batch)+1)
|
||||
errs = append(errs, err)
|
||||
for _, save := range batch {
|
||||
if recordErr := f.queue.RecordFailure(save, f.retry, f.deadLetters, err); recordErr != nil {
|
||||
errs = append(errs, recordErr)
|
||||
}
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
if err := f.expireBatch(ctx, batch); err != nil {
|
||||
errs := make([]error, 0, len(batch)+1)
|
||||
errs = append(errs, err)
|
||||
for _, save := range batch {
|
||||
if recordErr := f.queue.RecordFailure(save, f.retry, f.deadLetters, err); recordErr != nil {
|
||||
errs = append(errs, recordErr)
|
||||
}
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
continue
|
||||
}
|
||||
for i, save := range batch {
|
||||
if err := ctx.Err(); err != nil {
|
||||
f.queue.Restore(batch[i:])
|
||||
return err
|
||||
}
|
||||
// 底层 SaveFields 可能有编码或归一化副作用,单条刷盘必须使用独立快照。
|
||||
flushRecord := f.clone(save.profile)
|
||||
if err := f.saveFields(ctx, f.base, flushRecord, save.fields...); err != nil {
|
||||
recordErr := f.queue.RecordFailure(save, f.retry, f.deadLetters, err)
|
||||
f.queue.Restore(batch[i+1:])
|
||||
return errors.Join(err, recordErr)
|
||||
}
|
||||
if err := f.expireSave(ctx, save); err != nil {
|
||||
recordErr := f.queue.RecordFailure(save, f.retry, f.deadLetters, err)
|
||||
f.queue.Restore(batch[i+1:])
|
||||
return errors.Join(err, recordErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// AutoExpireTTL 返回自动续期 TTL。
|
||||
func (f *asyncFlusher[T, F]) AutoExpireTTL() time.Duration {
|
||||
if f == nil {
|
||||
return 0
|
||||
}
|
||||
return f.autoExpireTTL
|
||||
}
|
||||
|
||||
func (f *asyncFlusher[T, F]) expireBatch(ctx context.Context, batch []pendingSave[T, F]) error {
|
||||
if f.autoExpireTTL <= 0 || f.expire == nil {
|
||||
return nil
|
||||
}
|
||||
for _, save := range batch {
|
||||
if err := f.expireSave(ctx, save); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *asyncFlusher[T, F]) expireSave(ctx context.Context, save pendingSave[T, F]) error {
|
||||
if f.autoExpireTTL <= 0 || f.expire == nil {
|
||||
return nil
|
||||
}
|
||||
key, err := f.queue.RecordKey(save.profile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return f.expire(ctx, f.base, key, f.autoExpireTTL)
|
||||
}
|
||||
|
||||
func fieldSavesPending[T any, F comparable](batch []pendingSave[T, F], clone CloneFunc[T], normalize func([]F) []F) []FieldSave[T, F] {
|
||||
if len(batch) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]FieldSave[T, F], 0, len(batch))
|
||||
for _, save := range batch {
|
||||
save = save.clone(clone, normalize)
|
||||
out = append(out, FieldSave[T, F]{
|
||||
Record: save.profile,
|
||||
Fields: save.fields,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type pendingSave[T any, F comparable] struct {
|
||||
profile T
|
||||
fields []F
|
||||
attempts int
|
||||
nextAttemptAt time.Time
|
||||
firstFailedAt time.Time
|
||||
lastFailedAt time.Time
|
||||
lastError string
|
||||
sequence uint64
|
||||
}
|
||||
|
||||
type asyncPendingQueue[T any, F comparable] struct {
|
||||
mu sync.Mutex
|
||||
pending map[string]pendingSave[T, F]
|
||||
recordKey KeyFunc[T]
|
||||
normalizeKey func(string) string
|
||||
clone CloneFunc[T]
|
||||
normalizeFields func([]F) []F
|
||||
nextSequence uint64
|
||||
}
|
||||
|
||||
func newAsyncPendingQueue[T any, F comparable](
|
||||
recordKey KeyFunc[T],
|
||||
normalizeKey func(string) string,
|
||||
clone CloneFunc[T],
|
||||
normalizeFields func([]F) []F,
|
||||
) *asyncPendingQueue[T, F] {
|
||||
if clone == nil {
|
||||
clone = IdentityClone[T]
|
||||
}
|
||||
return &asyncPendingQueue[T, F]{
|
||||
pending: make(map[string]pendingSave[T, F]),
|
||||
recordKey: recordKey,
|
||||
normalizeKey: normalizeKey,
|
||||
clone: clone,
|
||||
normalizeFields: normalizeFields,
|
||||
}
|
||||
}
|
||||
|
||||
// Get 返回 pending 队列里的记录副本。
|
||||
func (q *asyncPendingQueue[T, F]) Get(accountID string) (pendingSave[T, F], bool) {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
save, ok := q.pending[accountID]
|
||||
if !ok {
|
||||
return pendingSave[T, F]{}, false
|
||||
}
|
||||
return save.clone(q.clone, q.normalizeFields), true
|
||||
}
|
||||
|
||||
// Add 添加或合并 pending 保存请求。
|
||||
func (q *asyncPendingQueue[T, F]) Add(accountID string, save pendingSave[T, F]) int {
|
||||
accountID = q.NormalizeRecordKey(accountID)
|
||||
save = save.clone(q.clone, q.normalizeFields)
|
||||
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
if existing, ok := q.pending[accountID]; ok {
|
||||
merged := existing.clone(q.clone, q.normalizeFields)
|
||||
merged.profile = q.clone(save.profile)
|
||||
merged.fields = q.Merge(merged.fields, save.fields)
|
||||
save = merged
|
||||
}
|
||||
if save.sequence == 0 {
|
||||
q.nextSequence++
|
||||
save.sequence = q.nextSequence
|
||||
}
|
||||
q.pending[accountID] = save
|
||||
return len(q.pending)
|
||||
}
|
||||
|
||||
// Requeue 把失败保存重新放回 pending 队列。
|
||||
func (q *asyncPendingQueue[T, F]) Requeue(accountID string, save pendingSave[T, F]) int {
|
||||
accountID = q.NormalizeRecordKey(accountID)
|
||||
save = save.clone(q.clone, q.normalizeFields)
|
||||
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
if existing, ok := q.pending[accountID]; ok {
|
||||
merged := existing.clone(q.clone, q.normalizeFields)
|
||||
merged.fields = q.Merge(merged.fields, save.fields)
|
||||
if merged.sequence == 0 {
|
||||
q.nextSequence++
|
||||
merged.sequence = q.nextSequence
|
||||
}
|
||||
q.pending[accountID] = merged
|
||||
return len(q.pending)
|
||||
}
|
||||
if save.sequence == 0 {
|
||||
q.nextSequence++
|
||||
save.sequence = q.nextSequence
|
||||
}
|
||||
q.pending[accountID] = save
|
||||
return len(q.pending)
|
||||
}
|
||||
|
||||
// Take 取出当前到期的 pending 保存请求。
|
||||
func (q *asyncPendingQueue[T, F]) Take(force bool, now time.Time) []pendingSave[T, F] {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
if len(q.pending) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
out := make([]keyedPendingSave[T, F], 0, len(q.pending))
|
||||
for accountID, save := range q.pending {
|
||||
if !force && !save.nextAttemptAt.IsZero() && now.Before(save.nextAttemptAt) {
|
||||
continue
|
||||
}
|
||||
out = append(out, keyedPendingSave[T, F]{
|
||||
key: accountID,
|
||||
save: save.clone(q.clone, q.normalizeFields),
|
||||
})
|
||||
delete(q.pending, accountID)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].save.sequence == out[j].save.sequence {
|
||||
return out[i].key < out[j].key
|
||||
}
|
||||
return out[i].save.sequence < out[j].save.sequence
|
||||
})
|
||||
|
||||
saves := make([]pendingSave[T, F], 0, len(out))
|
||||
for _, item := range out {
|
||||
saves = append(saves, item.save)
|
||||
}
|
||||
return saves
|
||||
}
|
||||
|
||||
// Restore 把未尝试的保存请求恢复到 pending 队列。
|
||||
func (q *asyncPendingQueue[T, F]) Restore(saves []pendingSave[T, F]) {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
for _, save := range saves {
|
||||
accountID, err := q.RecordKey(save.profile)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if existing, exists := q.pending[accountID]; exists {
|
||||
merged := existing.clone(q.clone, q.normalizeFields)
|
||||
merged.fields = q.Merge(merged.fields, save.fields)
|
||||
q.pending[accountID] = merged
|
||||
continue
|
||||
}
|
||||
if save.sequence == 0 {
|
||||
q.nextSequence++
|
||||
save.sequence = q.nextSequence
|
||||
}
|
||||
q.pending[accountID] = save.clone(q.clone, q.normalizeFields)
|
||||
}
|
||||
}
|
||||
|
||||
// RecordFailure 记录保存失败,并按重试策略放回队列或转入死信。
|
||||
func (q *asyncPendingQueue[T, F]) RecordFailure(save pendingSave[T, F], retry asyncRetryPolicy[T, F], sink *asyncDeadLetterSink[T, F], err error) error {
|
||||
save = retry.RecordFailure(save, err, time.Now().UTC())
|
||||
accountID, keyErr := q.RecordKey(save.profile)
|
||||
if keyErr != nil {
|
||||
return keyErr
|
||||
}
|
||||
|
||||
q.mu.Lock()
|
||||
if existing, exists := q.pending[accountID]; exists {
|
||||
merged := existing.clone(q.clone, q.normalizeFields)
|
||||
merged.fields = q.Merge(merged.fields, save.fields)
|
||||
if save.sequence == 0 || merged.sequence <= save.sequence {
|
||||
merged.attempts = save.attempts
|
||||
merged.firstFailedAt = save.firstFailedAt
|
||||
merged.lastFailedAt = save.lastFailedAt
|
||||
merged.lastError = save.lastError
|
||||
merged.nextAttemptAt = save.nextAttemptAt
|
||||
}
|
||||
if merged.sequence == 0 {
|
||||
q.nextSequence++
|
||||
merged.sequence = q.nextSequence
|
||||
}
|
||||
if retry.Exhausted(merged) {
|
||||
delete(q.pending, accountID)
|
||||
q.mu.Unlock()
|
||||
if err := sink.Add(merged); err != nil {
|
||||
q.Requeue(accountID, merged)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
q.pending[accountID] = merged
|
||||
q.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
if retry.Exhausted(save) {
|
||||
q.mu.Unlock()
|
||||
if err := sink.Add(save); err != nil {
|
||||
q.Requeue(accountID, save)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if save.sequence == 0 {
|
||||
q.nextSequence++
|
||||
save.sequence = q.nextSequence
|
||||
}
|
||||
q.pending[accountID] = save.clone(q.clone, q.normalizeFields)
|
||||
q.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stats 返回 pending 总数和当前可重试数量。
|
||||
func (q *asyncPendingQueue[T, F]) Stats(now time.Time) (int, int) {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
due := 0
|
||||
for _, save := range q.pending {
|
||||
if save.nextAttemptAt.IsZero() || !now.Before(save.nextAttemptAt) {
|
||||
due++
|
||||
}
|
||||
}
|
||||
return len(q.pending), due
|
||||
}
|
||||
|
||||
// RecordKey 计算并归一化记录主键。
|
||||
func (q *asyncPendingQueue[T, F]) RecordKey(record T) (string, error) {
|
||||
key, err := RecordKey(q.recordKey, record)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return q.NormalizeRecordKey(key), nil
|
||||
}
|
||||
|
||||
// NormalizeRecordKey 归一化记录主键。
|
||||
func (q *asyncPendingQueue[T, F]) NormalizeRecordKey(key string) string {
|
||||
if q.normalizeKey == nil {
|
||||
return key
|
||||
}
|
||||
return q.normalizeKey(key)
|
||||
}
|
||||
|
||||
// NormalizeFields 归一化字段列表。
|
||||
func (q *asyncPendingQueue[T, F]) NormalizeFields(fields []F) []F {
|
||||
if q.normalizeFields == nil {
|
||||
return append([]F(nil), fields...)
|
||||
}
|
||||
return q.normalizeFields(fields)
|
||||
}
|
||||
|
||||
// Merge 合并两组字段并归一化。
|
||||
func (q *asyncPendingQueue[T, F]) Merge(left, right []F) []F {
|
||||
merged := append(append([]F(nil), left...), right...)
|
||||
return q.NormalizeFields(merged)
|
||||
}
|
||||
|
||||
// Clone 拷贝记录。
|
||||
func (q *asyncPendingQueue[T, F]) Clone(record T) T {
|
||||
return q.clone(record)
|
||||
}
|
||||
|
||||
type keyedPendingSave[T any, F comparable] struct {
|
||||
key string
|
||||
save pendingSave[T, F]
|
||||
}
|
||||
|
||||
func (s pendingSave[T, F]) clone(clone CloneFunc[T], normalize func([]F) []F) pendingSave[T, F] {
|
||||
if clone == nil {
|
||||
clone = IdentityClone[T]
|
||||
}
|
||||
s.profile = clone(s.profile)
|
||||
if normalize != nil {
|
||||
s.fields = normalize(s.fields)
|
||||
} else {
|
||||
s.fields = append([]F(nil), s.fields...)
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package db
|
||||
|
||||
import "time"
|
||||
|
||||
type asyncRetryPolicy[T any, F comparable] struct {
|
||||
backoff time.Duration
|
||||
maxRetries int
|
||||
}
|
||||
|
||||
// RecordFailure 更新失败次数、失败时间和下一次重试时间。
|
||||
func (p asyncRetryPolicy[T, F]) RecordFailure(save pendingSave[T, F], err error, now time.Time) pendingSave[T, F] {
|
||||
save.attempts++
|
||||
if save.firstFailedAt.IsZero() {
|
||||
save.firstFailedAt = now
|
||||
}
|
||||
save.lastFailedAt = now
|
||||
if err != nil {
|
||||
save.lastError = err.Error()
|
||||
}
|
||||
save.nextAttemptAt = now.Add(p.backoff * time.Duration(save.attempts))
|
||||
return save
|
||||
}
|
||||
|
||||
// Exhausted 判断保存请求是否已耗尽重试次数。
|
||||
func (p asyncRetryPolicy[T, F]) Exhausted(save pendingSave[T, F]) bool {
|
||||
return p.maxRetries > 0 && save.attempts >= p.maxRetries
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AsyncStoreOptions 配置异步写后队列、重试和死信存储。
|
||||
type AsyncStoreOptions[T any, F comparable] struct {
|
||||
FlushInterval time.Duration
|
||||
MaxPending int
|
||||
RetryBackoff time.Duration
|
||||
MaxRetries int
|
||||
DeadLetterLimit int
|
||||
DeadLetterStore DeadLetterStore[T, F]
|
||||
|
||||
RecordKey KeyFunc[T]
|
||||
NormalizeKey func(string) string
|
||||
Clone CloneFunc[T]
|
||||
AllFields func() []F
|
||||
NormalizeFields func([]F) []F
|
||||
SaveFields SaveFieldsFunc[T, F]
|
||||
SaveBatch SaveFieldBatchFunc[T, F]
|
||||
AutoExpire bool
|
||||
AutoExpireTTL time.Duration
|
||||
Expire ExpireFunc[T]
|
||||
ClosedError error
|
||||
}
|
||||
|
||||
// AsyncStoreStats 描述异步存储当前运行状态。
|
||||
type AsyncStoreStats struct {
|
||||
Pending int `json:"pending"`
|
||||
PendingDue int `json:"pending_due"`
|
||||
DeadLetters int `json:"dead_letters"`
|
||||
Closed bool `json:"closed"`
|
||||
FlushInterval string `json:"flush_interval"`
|
||||
RetryBackoff string `json:"retry_backoff"`
|
||||
MaxPending int `json:"max_pending"`
|
||||
MaxRetries int `json:"max_retries"`
|
||||
AutoExpireTTL string `json:"auto_expire_ttl,omitempty"`
|
||||
DeadLetterLimit int `json:"dead_letter_limit"`
|
||||
DeadLetterStore string `json:"dead_letter_store,omitempty"`
|
||||
DeadLetterStoreError string `json:"dead_letter_store_error,omitempty"`
|
||||
}
|
||||
|
||||
const defAsyncFlush = 5 * time.Second
|
||||
|
||||
// AsyncStore 用内存队列包装同步 Store,实现异步刷盘和死信兜底。
|
||||
type AsyncStore[T any, F comparable] struct {
|
||||
base Store[T]
|
||||
flushInterval time.Duration
|
||||
maxPending int
|
||||
allFields func() []F
|
||||
closedError error
|
||||
|
||||
mu sync.Mutex
|
||||
closed bool
|
||||
|
||||
queue *asyncPendingQueue[T, F]
|
||||
retry asyncRetryPolicy[T, F]
|
||||
deadLetters *asyncDeadLetterSink[T, F]
|
||||
flusher *asyncFlusher[T, F]
|
||||
|
||||
done chan struct{}
|
||||
flushNow chan struct{}
|
||||
stopped chan struct{}
|
||||
stopOnce sync.Once
|
||||
}
|
||||
|
||||
// NewAsyncStore 用写后队列包装同步 Store。
|
||||
// Load 会先读 pending 和 dead-letter,再读 base store;这样即使后台刷盘还没落到持久层,
|
||||
// 调用方也能看到自己已被接受的最新 mutation。
|
||||
func NewAsyncStore[T any, F comparable](base Store[T], options AsyncStoreOptions[T, F]) (*AsyncStore[T, F], error) {
|
||||
if base == nil {
|
||||
return nil, fmt.Errorf("db.AsyncStore requires a base store")
|
||||
}
|
||||
if options.AutoExpire && options.AutoExpireTTL <= 0 {
|
||||
return nil, fmt.Errorf("db.AsyncStore auto expire ttl must be positive")
|
||||
}
|
||||
return newAsyncStore(base, options), nil
|
||||
}
|
||||
|
||||
// MustNewAsyncStore 创建异步存储,失败时 panic,适合启动期固定配置。
|
||||
func MustNewAsyncStore[T any, F comparable](base Store[T], options AsyncStoreOptions[T, F]) *AsyncStore[T, F] {
|
||||
store, err := NewAsyncStore(base, options)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return store
|
||||
}
|
||||
|
||||
func newAsyncStore[T any, F comparable](base Store[T], options AsyncStoreOptions[T, F]) *AsyncStore[T, F] {
|
||||
if options.RetryBackoff < 0 {
|
||||
options.RetryBackoff = time.Second
|
||||
}
|
||||
if options.DeadLetterLimit <= 0 {
|
||||
options.DeadLetterLimit = 128
|
||||
}
|
||||
if options.Clone == nil {
|
||||
options.Clone = IdentityClone[T]
|
||||
}
|
||||
if options.SaveFields == nil {
|
||||
options.SaveFields = func(ctx context.Context, store Store[T], record T, fields ...F) error {
|
||||
return SaveFields(ctx, store, record, options.NormalizeFields, fields...)
|
||||
}
|
||||
}
|
||||
if options.SaveBatch == nil {
|
||||
if _, ok := base.(BatchFieldStore[T, F]); ok {
|
||||
options.SaveBatch = func(ctx context.Context, store Store[T], saves []FieldSave[T, F]) error {
|
||||
return SaveFieldBatch(ctx, store, options.NormalizeFields, saves)
|
||||
}
|
||||
}
|
||||
}
|
||||
if options.AutoExpire && options.Expire == nil {
|
||||
options.Expire = func(ctx context.Context, store Store[T], key string, ttl time.Duration) error {
|
||||
return Expire(ctx, store, key, ttl)
|
||||
}
|
||||
}
|
||||
if options.ClosedError == nil {
|
||||
options.ClosedError = ErrAsyncStoreClosed
|
||||
}
|
||||
|
||||
queue := newAsyncPendingQueue(options.RecordKey, options.NormalizeKey, options.Clone, options.NormalizeFields)
|
||||
retry := asyncRetryPolicy[T, F]{backoff: options.RetryBackoff, maxRetries: options.MaxRetries}
|
||||
deadLetters := newAsyncDeadSink(asyncDeadSinkOpts[T, F]{
|
||||
Limit: options.DeadLetterLimit,
|
||||
Store: options.DeadLetterStore,
|
||||
RecordKey: options.RecordKey,
|
||||
NormalizeKey: options.NormalizeKey,
|
||||
Clone: options.Clone,
|
||||
NormalizeFields: options.NormalizeFields,
|
||||
})
|
||||
flusher := newAsyncFlusher(asyncFlusherOptions[T, F]{
|
||||
Base: base,
|
||||
Queue: queue,
|
||||
Retry: retry,
|
||||
DeadLetters: deadLetters,
|
||||
Clone: options.Clone,
|
||||
NormalizeFields: options.NormalizeFields,
|
||||
SaveFields: options.SaveFields,
|
||||
SaveBatch: options.SaveBatch,
|
||||
AutoExpireTTL: options.AutoExpireTTL,
|
||||
Expire: options.Expire,
|
||||
})
|
||||
|
||||
store := &AsyncStore[T, F]{
|
||||
base: base,
|
||||
flushInterval: options.FlushInterval,
|
||||
maxPending: options.MaxPending,
|
||||
allFields: options.AllFields,
|
||||
closedError: options.ClosedError,
|
||||
queue: queue,
|
||||
retry: retry,
|
||||
deadLetters: deadLetters,
|
||||
flusher: flusher,
|
||||
done: make(chan struct{}),
|
||||
flushNow: make(chan struct{}, 1),
|
||||
stopped: make(chan struct{}),
|
||||
}
|
||||
store.deadLetters.Load()
|
||||
go store.run()
|
||||
return store
|
||||
}
|
||||
|
||||
// Load 优先读取 pending 和 dead-letter,再读取底层存储。
|
||||
func (s *AsyncStore[T, F]) Load(ctx context.Context, accountID string) (T, bool, error) {
|
||||
ctx = contextOrBackground(ctx)
|
||||
accountID = s.queue.NormalizeRecordKey(accountID)
|
||||
if pending, ok := s.queue.Get(accountID); ok {
|
||||
return s.queue.Clone(pending.profile), true, nil
|
||||
}
|
||||
if dead, ok := s.deadLetters.Get(accountID); ok {
|
||||
return s.queue.Clone(dead.Profile), true, nil
|
||||
}
|
||||
return s.base.Load(ctx, accountID)
|
||||
}
|
||||
|
||||
// Check 检查异步存储、底层存储和死信存储状态。
|
||||
func (s *AsyncStore[T, F]) Check(ctx context.Context) error {
|
||||
ctx = contextOrBackground(ctx)
|
||||
s.mu.Lock()
|
||||
closed := s.closed
|
||||
base := s.base
|
||||
closedError := s.closedError
|
||||
s.mu.Unlock()
|
||||
|
||||
if closed {
|
||||
return closedError
|
||||
}
|
||||
if deadLetterStoreError := s.deadLetters.LastError(); deadLetterStoreError != "" {
|
||||
return fmt.Errorf("dead letter store: %s", deadLetterStoreError)
|
||||
}
|
||||
if err := Check(ctx, base); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.deadLetters.CheckStore(ctx)
|
||||
}
|
||||
|
||||
// Save 保存完整记录字段。
|
||||
func (s *AsyncStore[T, F]) Save(ctx context.Context, profile T) error {
|
||||
fields := s.allFieldsForSave()
|
||||
if len(fields) == 0 {
|
||||
return ErrAllFieldsRequired
|
||||
}
|
||||
return s.SaveFields(ctx, profile, fields...)
|
||||
}
|
||||
|
||||
// SaveFields 把局部字段写入 pending 队列。
|
||||
func (s *AsyncStore[T, F]) SaveFields(ctx context.Context, profile T, fields ...F) error {
|
||||
ctx = contextOrBackground(ctx)
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
accountID, err := s.queue.RecordKey(profile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fields = s.queue.NormalizeFields(fields)
|
||||
if len(fields) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
save := pendingSave[T, F]{
|
||||
profile: s.queue.Clone(profile),
|
||||
fields: fields,
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
if s.closed {
|
||||
s.mu.Unlock()
|
||||
return s.closedError
|
||||
}
|
||||
pending := s.queue.Add(accountID, save)
|
||||
s.mu.Unlock()
|
||||
|
||||
if s.maxPending > 0 && pending >= s.maxPending {
|
||||
s.requestFlush()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Flush 强制刷出当前可写 pending 队列。
|
||||
func (s *AsyncStore[T, F]) Flush(ctx context.Context) error {
|
||||
ctx = contextOrBackground(ctx)
|
||||
return s.flusher.Flush(ctx, true)
|
||||
}
|
||||
|
||||
// Stats 返回异步队列、死信和重试状态。
|
||||
func (s *AsyncStore[T, F]) Stats() AsyncStoreStats {
|
||||
now := time.Now().UTC()
|
||||
pending, pendingDue := s.queue.Stats(now)
|
||||
|
||||
s.mu.Lock()
|
||||
closed := s.closed
|
||||
s.mu.Unlock()
|
||||
|
||||
return AsyncStoreStats{
|
||||
Pending: pending,
|
||||
PendingDue: pendingDue,
|
||||
DeadLetters: s.deadLetters.Count(),
|
||||
Closed: closed,
|
||||
FlushInterval: s.flushInterval.String(),
|
||||
RetryBackoff: s.retry.backoff.String(),
|
||||
MaxPending: s.maxPending,
|
||||
MaxRetries: s.retry.maxRetries,
|
||||
AutoExpireTTL: asyncDurationString(s.flusher.AutoExpireTTL()),
|
||||
DeadLetterLimit: s.deadLetters.Limit(),
|
||||
DeadLetterStore: s.deadLetters.StoreName(),
|
||||
DeadLetterStoreError: s.deadLetters.LastError(),
|
||||
}
|
||||
}
|
||||
|
||||
// DeadLetters 返回当前死信快照。
|
||||
func (s *AsyncStore[T, F]) DeadLetters() []AsyncStoreDeadLetter[T, F] {
|
||||
return s.deadLetters.List()
|
||||
}
|
||||
|
||||
// RequeueDeadLetter 把指定死信重新放回 pending 队列。
|
||||
func (s *AsyncStore[T, F]) RequeueDeadLetter(accountID string) bool {
|
||||
accountID = s.queue.NormalizeRecordKey(accountID)
|
||||
s.mu.Lock()
|
||||
if s.closed {
|
||||
s.mu.Unlock()
|
||||
return false
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
dead, ok := s.deadLetters.Take(accountID)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
fields := s.queue.NormalizeFields(dead.Fields)
|
||||
if len(fields) == 0 {
|
||||
fields = s.allFieldsForSave()
|
||||
}
|
||||
save := pendingSave[T, F]{
|
||||
profile: s.queue.Clone(dead.Profile),
|
||||
fields: fields,
|
||||
attempts: dead.Attempts,
|
||||
lastError: dead.Error,
|
||||
firstFailedAt: dead.FirstFailedAt,
|
||||
lastFailedAt: dead.LastFailedAt,
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.closed {
|
||||
if err := s.deadLetters.Add(save); err != nil {
|
||||
s.deadLetters.restoreMemory(dead)
|
||||
}
|
||||
return false
|
||||
}
|
||||
s.queue.Requeue(accountID, save)
|
||||
return true
|
||||
}
|
||||
|
||||
// Close 停止后台刷盘并做最后一次 Flush。
|
||||
func (s *AsyncStore[T, F]) Close(ctx context.Context) error {
|
||||
ctx, cancel := asyncCloseCtx(ctx)
|
||||
defer cancel()
|
||||
s.mu.Lock()
|
||||
s.closed = true
|
||||
s.mu.Unlock()
|
||||
|
||||
s.stopOnce.Do(func() {
|
||||
close(s.done)
|
||||
})
|
||||
select {
|
||||
case <-s.stopped:
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
return errors.Join(s.Flush(ctx), CloseOrFlush(ctx, s.base))
|
||||
}
|
||||
|
||||
func asyncCloseCtx(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if _, ok := ctx.Deadline(); ok {
|
||||
return ctx, func() {}
|
||||
}
|
||||
return context.WithTimeout(ctx, defAsyncFlush)
|
||||
}
|
||||
|
||||
func (s *AsyncStore[T, F]) run() {
|
||||
defer close(s.stopped)
|
||||
if s.flushInterval <= 0 {
|
||||
for {
|
||||
select {
|
||||
case <-s.flushNow:
|
||||
s.flushBackground()
|
||||
case <-s.done:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(s.flushInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
s.flushBackground()
|
||||
case <-s.flushNow:
|
||||
s.flushBackground()
|
||||
case <-s.done:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AsyncStore[T, F]) requestFlush() {
|
||||
select {
|
||||
case s.flushNow <- struct{}{}:
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AsyncStore[T, F]) flushBackground() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), defAsyncFlush)
|
||||
_ = s.flusher.Flush(ctx, false)
|
||||
cancel()
|
||||
}
|
||||
|
||||
func (s *AsyncStore[T, F]) allFieldsForSave() []F {
|
||||
if s.allFields == nil {
|
||||
return nil
|
||||
}
|
||||
return s.queue.NormalizeFields(s.allFields())
|
||||
}
|
||||
|
||||
func asyncDurationString(duration time.Duration) string {
|
||||
if duration <= 0 {
|
||||
return ""
|
||||
}
|
||||
return duration.String()
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
// WriteFileAtomically 通过同目录临时文件、fsync 和原子替换持久化写入单个文件。
|
||||
func WriteFileAtomically(ctx context.Context, path string, data []byte, perm fs.FileMode) error {
|
||||
ctx = contextOrBackground(ctx)
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if path == "" {
|
||||
return fmt.Errorf("atomic file path is required")
|
||||
}
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp, err := os.CreateTemp(dir, "."+filepath.Base(path)+".*.tmp")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
keepTmp := true
|
||||
defer func() {
|
||||
if keepTmp {
|
||||
_ = os.Remove(tmpName)
|
||||
}
|
||||
}()
|
||||
if err := writeAndSyncTempFile(tmp, data, perm); err != nil {
|
||||
_ = tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := replaceFile(tmpName, path); err != nil {
|
||||
return err
|
||||
}
|
||||
keepTmp = false
|
||||
return syncDir(dir)
|
||||
}
|
||||
|
||||
func writeAndSyncTempFile(file *os.File, data []byte, perm fs.FileMode) error {
|
||||
written, err := file.Write(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if written != len(data) {
|
||||
return io.ErrShortWrite
|
||||
}
|
||||
if err := file.Chmod(perm); err != nil {
|
||||
return err
|
||||
}
|
||||
return file.Sync()
|
||||
}
|
||||
|
||||
func replaceFile(tmpName, path string) error {
|
||||
if err := os.Rename(tmpName, path); err == nil {
|
||||
return nil
|
||||
} else if _, statErr := os.Stat(path); statErr != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
backupPath := tmpName + ".bak"
|
||||
if err := os.Rename(path, backupPath); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmpName, path); err != nil {
|
||||
restoreErr := os.Rename(backupPath, path)
|
||||
return errors.Join(err, restoreErr)
|
||||
}
|
||||
if err := os.Remove(backupPath); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func syncDir(dir string) error {
|
||||
file, err := os.Open(dir) //nolint:gosec // G304:目录来自调用点限定的持久化路径。
|
||||
if err != nil {
|
||||
if runtime.GOOS == "windows" {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
defer func() { _ = file.Close() }()
|
||||
if err := file.Sync(); err != nil && runtime.GOOS != "windows" {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// BatchLoader 支持按主键批量加载记录。
|
||||
type BatchLoader[T any] interface {
|
||||
LoadBatch(context.Context, []string) (map[string]T, error)
|
||||
}
|
||||
|
||||
// LoadBatch 批量加载记录;底层不支持批量时逐条加载。
|
||||
func LoadBatch[T any](ctx context.Context, store Store[T], keys []string, normalizeKey func(string) string) (map[string]T, error) {
|
||||
if store == nil {
|
||||
return nil, errors.New("store is nil")
|
||||
}
|
||||
ctx = contextOrBackground(ctx)
|
||||
keys = normalizeBatchKeys(keys, normalizeKey)
|
||||
if len(keys) == 0 {
|
||||
return map[string]T{}, ctx.Err()
|
||||
}
|
||||
if loader, ok := store.(BatchLoader[T]); ok {
|
||||
return loader.LoadBatch(ctx, keys)
|
||||
}
|
||||
out := make(map[string]T, len(keys))
|
||||
for _, key := range keys {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
record, ok, err := store.Load(ctx, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ok {
|
||||
out[key] = record
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func normalizeBatchKeys(keys []string, normalizeKey func(string) string) []string {
|
||||
if len(keys) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[string]struct{}, len(keys))
|
||||
for _, key := range keys {
|
||||
key = normalizeLookupKey(key, normalizeKey)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
}
|
||||
out := make([]string, 0, len(seen))
|
||||
for key := range seen {
|
||||
out = append(out, key)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeLookupKey(key string, normalizeKey func(string) string) string {
|
||||
if normalizeKey != nil {
|
||||
key = normalizeKey(key)
|
||||
}
|
||||
return strings.TrimSpace(key)
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AsyncStoreDeadLetter 描述异步存储刷盘失败后的死信记录。
|
||||
type AsyncStoreDeadLetter[T any, F comparable] struct {
|
||||
AccountID string `json:"account_id"`
|
||||
Profile T `json:"profile"`
|
||||
Fields []F `json:"fields,omitempty"`
|
||||
Attempts int `json:"attempts"`
|
||||
Error string `json:"error"`
|
||||
FirstFailedAt time.Time `json:"first_failed_at"`
|
||||
LastFailedAt time.Time `json:"last_failed_at"`
|
||||
}
|
||||
|
||||
// DeadLetterStore 是死信持久化接口。
|
||||
type DeadLetterStore[T any, F comparable] interface {
|
||||
Save(context.Context, AsyncStoreDeadLetter[T, F]) error
|
||||
Delete(context.Context, string) error
|
||||
List(context.Context) ([]AsyncStoreDeadLetter[T, F], error)
|
||||
}
|
||||
|
||||
// FileDeadLetterStoreOptions 配置文件死信存储目录。
|
||||
type FileDeadLetterStoreOptions struct {
|
||||
Directory string
|
||||
DefaultDirectory string
|
||||
}
|
||||
|
||||
// FileDeadLetterStore 用本地 JSON 文件保存死信。
|
||||
type FileDeadLetterStore[T any, F comparable] struct {
|
||||
mu sync.RWMutex
|
||||
dir string
|
||||
}
|
||||
|
||||
// NewFileDeadLetterStore 创建文件死信存储。
|
||||
func NewFileDeadLetterStore[T any, F comparable](options FileDeadLetterStoreOptions) *FileDeadLetterStore[T, F] {
|
||||
dir := strings.TrimSpace(options.Directory)
|
||||
if dir == "" {
|
||||
dir = strings.TrimSpace(options.DefaultDirectory)
|
||||
}
|
||||
if dir == "" {
|
||||
dir = "data/dead_letters"
|
||||
}
|
||||
return &FileDeadLetterStore[T, F]{dir: dir}
|
||||
}
|
||||
|
||||
// Save 原子写入一条死信文件。
|
||||
func (s *FileDeadLetterStore[T, F]) Save(ctx context.Context, dead AsyncStoreDeadLetter[T, F]) error {
|
||||
ctx = contextOrBackground(ctx)
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
path, err := s.deadLetterPath(dead.AccountID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.MarshalIndent(dead, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
return WriteFileAtomically(ctx, path, data, 0o600)
|
||||
}
|
||||
|
||||
// Check 检查死信目录是否可写。
|
||||
func (s *FileDeadLetterStore[T, F]) Check(ctx context.Context) error {
|
||||
return checkWritableDir(ctx, s.dir)
|
||||
}
|
||||
|
||||
// Delete 删除指定账号死信文件。
|
||||
func (s *FileDeadLetterStore[T, F]) Delete(ctx context.Context, accountID string) error {
|
||||
ctx = contextOrBackground(ctx)
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
path, err := s.deadLetterPath(accountID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// List 读取并按失败时间排序全部死信。
|
||||
func (s *FileDeadLetterStore[T, F]) List(ctx context.Context) ([]AsyncStoreDeadLetter[T, F], error) {
|
||||
ctx = contextOrBackground(ctx)
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
entries, err := os.ReadDir(s.dir)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]AsyncStoreDeadLetter[T, F], 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" {
|
||||
continue
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(s.dir, entry.Name()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var dead AsyncStoreDeadLetter[T, F]
|
||||
if err := json.Unmarshal(data, &dead); err != nil {
|
||||
return nil, fmt.Errorf("decode dead letter %s: %w", entry.Name(), err)
|
||||
}
|
||||
out = append(out, dead)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].LastFailedAt.Equal(out[j].LastFailedAt) {
|
||||
return out[i].AccountID < out[j].AccountID
|
||||
}
|
||||
return out[i].LastFailedAt.Before(out[j].LastFailedAt)
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *FileDeadLetterStore[T, F]) deadLetterPath(accountID string) (string, error) {
|
||||
accountID = strings.TrimSpace(accountID)
|
||||
if accountID == "" {
|
||||
return "", ErrRecordKeyRequired
|
||||
}
|
||||
name := base64.RawURLEncoding.EncodeToString([]byte(accountID)) + ".json"
|
||||
return filepath.Join(s.dir, name), nil
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// DirtyRecordOptions 配置脏字段记录的初始值和字段集合。
|
||||
type DirtyRecordOptions[T any, F comparable] struct {
|
||||
Record T
|
||||
Fields FieldRegistry[F]
|
||||
Clone CloneFunc[T]
|
||||
Dirty []F
|
||||
MarkAllDirty bool
|
||||
}
|
||||
|
||||
// DirtySaveResult 描述一次脏字段保存结果。
|
||||
type DirtySaveResult[F comparable] struct {
|
||||
Saved bool
|
||||
Fields []F
|
||||
Remaining []F
|
||||
}
|
||||
|
||||
// DirtyRecord 在内存中维护记录快照和脏字段集合。
|
||||
type DirtyRecord[T any, F comparable] struct {
|
||||
mu sync.RWMutex
|
||||
record T
|
||||
fields FieldRegistry[F]
|
||||
clone CloneFunc[T]
|
||||
dirty map[F]uint64
|
||||
revision uint64
|
||||
}
|
||||
|
||||
// NewDirtyRecord 创建脏字段记录。
|
||||
func NewDirtyRecord[T any, F comparable](options DirtyRecordOptions[T, F]) *DirtyRecord[T, F] {
|
||||
clone := options.Clone
|
||||
if clone == nil {
|
||||
clone = IdentityClone[T]
|
||||
}
|
||||
r := &DirtyRecord[T, F]{
|
||||
record: clone(options.Record),
|
||||
fields: options.Fields,
|
||||
clone: clone,
|
||||
dirty: make(map[F]uint64),
|
||||
}
|
||||
if options.MarkAllDirty {
|
||||
r.markAllDirtyLocked()
|
||||
} else {
|
||||
r.markDirtyLocked(options.Dirty...)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// Record 返回当前记录副本。
|
||||
func (r *DirtyRecord[T, F]) Record() T {
|
||||
r.mu.RLock()
|
||||
record := r.clone(r.record)
|
||||
r.mu.RUnlock()
|
||||
return record
|
||||
}
|
||||
|
||||
// Snapshot 返回当前记录副本和脏字段列表。
|
||||
func (r *DirtyRecord[T, F]) Snapshot() (T, []F) {
|
||||
r.mu.RLock()
|
||||
record := r.clone(r.record)
|
||||
fields := r.dirtyFieldsLocked()
|
||||
r.mu.RUnlock()
|
||||
return record, fields
|
||||
}
|
||||
|
||||
// DirtyFields 返回当前脏字段列表。
|
||||
func (r *DirtyRecord[T, F]) DirtyFields() []F {
|
||||
r.mu.RLock()
|
||||
fields := r.dirtyFieldsLocked()
|
||||
r.mu.RUnlock()
|
||||
return fields
|
||||
}
|
||||
|
||||
// MarkDirty 标记指定字段为脏。
|
||||
func (r *DirtyRecord[T, F]) MarkDirty(fields ...F) {
|
||||
r.mu.Lock()
|
||||
r.markDirtyLocked(fields...)
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
// MarkAllDirty 标记全部字段为脏。
|
||||
func (r *DirtyRecord[T, F]) MarkAllDirty() {
|
||||
r.mu.Lock()
|
||||
r.markAllDirtyLocked()
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
// ClearDirty 清除指定字段的脏标记。
|
||||
func (r *DirtyRecord[T, F]) ClearDirty(fields ...F) {
|
||||
r.mu.Lock()
|
||||
for _, field := range r.fields.Normalize(fields) {
|
||||
delete(r.dirty, field)
|
||||
}
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
// ClearAllDirty 清除全部脏标记。
|
||||
func (r *DirtyRecord[T, F]) ClearAllDirty() {
|
||||
r.mu.Lock()
|
||||
clear(r.dirty)
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
// Replace 替换记录并标记指定字段。
|
||||
func (r *DirtyRecord[T, F]) Replace(record T, fields ...F) {
|
||||
r.mu.Lock()
|
||||
r.record = r.clone(record)
|
||||
r.markDirtyLocked(fields...)
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
// Mutate 在锁内修改记录,并返回修改前后副本。
|
||||
func (r *DirtyRecord[T, F]) Mutate(mutate func(*T) ([]F, error)) (T, T, error) {
|
||||
var zero T
|
||||
if mutate == nil {
|
||||
return zero, zero, errors.New("dirty record mutation is required")
|
||||
}
|
||||
r.mu.Lock()
|
||||
before := r.clone(r.record)
|
||||
next := r.clone(r.record)
|
||||
fields, err := mutate(&next)
|
||||
if err != nil {
|
||||
r.mu.Unlock()
|
||||
return before, before, err
|
||||
}
|
||||
r.record = r.clone(next)
|
||||
r.markDirtyLocked(fields...)
|
||||
after := r.clone(r.record)
|
||||
r.mu.Unlock()
|
||||
return before, after, nil
|
||||
}
|
||||
|
||||
// SaveDirty 保存当前脏字段,成功后只清除本次已保存字段。
|
||||
func (r *DirtyRecord[T, F]) SaveDirty(ctx context.Context, store Store[T]) (DirtySaveResult[F], error) {
|
||||
if store == nil {
|
||||
return DirtySaveResult[F]{}, errors.New("store is nil")
|
||||
}
|
||||
ctx = contextOrBackground(ctx)
|
||||
record, fields, revisions := r.saveSnapshot()
|
||||
if len(fields) == 0 {
|
||||
return DirtySaveResult[F]{Saved: false}, ctx.Err()
|
||||
}
|
||||
if err := SaveFields(ctx, store, record, r.fields.Normalize, fields...); err != nil {
|
||||
return DirtySaveResult[F]{Saved: false, Fields: append([]F(nil), fields...), Remaining: r.DirtyFields()}, err
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
for _, field := range fields {
|
||||
if current, ok := r.dirty[field]; ok && current == revisions[field] {
|
||||
delete(r.dirty, field)
|
||||
}
|
||||
}
|
||||
remaining := r.dirtyFieldsLocked()
|
||||
r.mu.Unlock()
|
||||
return DirtySaveResult[F]{
|
||||
Saved: true,
|
||||
Fields: append([]F(nil), fields...),
|
||||
Remaining: remaining,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *DirtyRecord[T, F]) saveSnapshot() (T, []F, map[F]uint64) {
|
||||
r.mu.RLock()
|
||||
record := r.clone(r.record)
|
||||
fields := r.dirtyFieldsLocked()
|
||||
revisions := make(map[F]uint64, len(fields))
|
||||
for _, field := range fields {
|
||||
revisions[field] = r.dirty[field]
|
||||
}
|
||||
r.mu.RUnlock()
|
||||
return record, fields, revisions
|
||||
}
|
||||
|
||||
func (r *DirtyRecord[T, F]) markDirtyLocked(fields ...F) {
|
||||
for _, field := range r.fields.Normalize(fields) {
|
||||
r.revision++
|
||||
r.dirty[field] = r.revision
|
||||
}
|
||||
}
|
||||
|
||||
func (r *DirtyRecord[T, F]) markAllDirtyLocked() {
|
||||
r.markDirtyLocked(r.fields.All()...)
|
||||
}
|
||||
|
||||
func (r *DirtyRecord[T, F]) dirtyFieldsLocked() []F {
|
||||
if len(r.dirty) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]F, 0, len(r.dirty))
|
||||
for _, field := range r.fields.All() {
|
||||
if _, ok := r.dirty[field]; ok {
|
||||
out = append(out, field)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
// Package db 提供 Profile、读模型、事件和 runtime 状态模块共用的存储契约。
|
||||
//
|
||||
// 这里刻意拆开克隆、字段级脏标记、异步刷盘、重试和死信处理,让玩法模块在不改
|
||||
// mutation 代码的前提下切换 memory、Redis、SQL 或 Mongo 后端。
|
||||
package db
|
||||
@@ -0,0 +1,155 @@
|
||||
package db
|
||||
|
||||
// FieldRegistry 保存字段的合法集合和稳定顺序。
|
||||
type FieldRegistry[F comparable] struct {
|
||||
order []F
|
||||
known map[F]struct{}
|
||||
}
|
||||
|
||||
// NewFieldRegistry 创建字段注册表。
|
||||
func NewFieldRegistry[F comparable](order []F) FieldRegistry[F] {
|
||||
registry := FieldRegistry[F]{
|
||||
order: append([]F(nil), order...),
|
||||
known: make(map[F]struct{}, len(order)),
|
||||
}
|
||||
for _, field := range order {
|
||||
registry.known[field] = struct{}{}
|
||||
}
|
||||
return registry
|
||||
}
|
||||
|
||||
// All 返回全部字段并保持注册顺序。
|
||||
func (r FieldRegistry[F]) All() []F {
|
||||
return append([]F(nil), r.order...)
|
||||
}
|
||||
|
||||
// IsKnown 判断字段是否已注册。
|
||||
func (r FieldRegistry[F]) IsKnown(field F) bool {
|
||||
_, ok := r.known[field]
|
||||
return ok
|
||||
}
|
||||
|
||||
// Normalize 去重、过滤未知字段,并按注册顺序返回。
|
||||
func (r FieldRegistry[F]) Normalize(fields []F) []F {
|
||||
if len(fields) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[F]struct{}, len(fields))
|
||||
for _, field := range fields {
|
||||
if r.IsKnown(field) {
|
||||
seen[field] = struct{}{}
|
||||
}
|
||||
}
|
||||
out := make([]F, 0, len(seen))
|
||||
for _, field := range r.order {
|
||||
if _, ok := seen[field]; ok {
|
||||
out = append(out, field)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Merge 合并两组字段并做归一化。
|
||||
func (r FieldRegistry[F]) Merge(left, right []F) []F {
|
||||
if len(left) == 0 {
|
||||
return r.Normalize(right)
|
||||
}
|
||||
if len(right) == 0 {
|
||||
return r.Normalize(left)
|
||||
}
|
||||
merged := make([]F, 0, len(left)+len(right))
|
||||
merged = append(merged, left...)
|
||||
merged = append(merged, right...)
|
||||
return r.Normalize(merged)
|
||||
}
|
||||
|
||||
// NewSet 创建字段集合。
|
||||
func (r FieldRegistry[F]) NewSet(fields ...F) FieldSet[F] {
|
||||
set := FieldSet[F]{
|
||||
registry: r,
|
||||
values: make(map[F]struct{}),
|
||||
}
|
||||
set.Add(fields...)
|
||||
return set
|
||||
}
|
||||
|
||||
// AllSet 创建包含所有字段的集合。
|
||||
func (r FieldRegistry[F]) AllSet() FieldSet[F] {
|
||||
set := r.NewSet()
|
||||
set.AddAll()
|
||||
return set
|
||||
}
|
||||
|
||||
// FieldSet 表示一组已注册字段。
|
||||
type FieldSet[F comparable] struct {
|
||||
registry FieldRegistry[F]
|
||||
values map[F]struct{}
|
||||
}
|
||||
|
||||
// IsZero 判断字段集合是否为空。
|
||||
func (s FieldSet[F]) IsZero() bool {
|
||||
return len(s.values) == 0
|
||||
}
|
||||
|
||||
// Add 向集合加入字段并忽略未知字段。
|
||||
func (s FieldSet[F]) Add(fields ...F) {
|
||||
if s.values == nil {
|
||||
return
|
||||
}
|
||||
for _, field := range fields {
|
||||
if s.registry.IsKnown(field) {
|
||||
s.values[field] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// AddAll 把注册表里的所有字段加入集合。
|
||||
func (s FieldSet[F]) AddAll() {
|
||||
if s.values == nil {
|
||||
return
|
||||
}
|
||||
for _, field := range s.registry.order {
|
||||
s.values[field] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
// Merge 合并另一个字段集合。
|
||||
func (s FieldSet[F]) Merge(other FieldSet[F]) {
|
||||
if s.values == nil {
|
||||
return
|
||||
}
|
||||
for field := range other.values {
|
||||
if s.registry.IsKnown(field) {
|
||||
s.values[field] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// List 按注册顺序返回字段列表。
|
||||
func (s FieldSet[F]) List() []F {
|
||||
if len(s.values) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]F, 0, len(s.values))
|
||||
for _, field := range s.registry.order {
|
||||
if _, ok := s.values[field]; ok {
|
||||
out = append(out, field)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Clone 拷贝字段集合。
|
||||
func (s FieldSet[F]) Clone() FieldSet[F] {
|
||||
if len(s.values) == 0 {
|
||||
return FieldSet[F]{registry: s.registry}
|
||||
}
|
||||
out := FieldSet[F]{
|
||||
registry: s.registry,
|
||||
values: make(map[F]struct{}, len(s.values)),
|
||||
}
|
||||
for field := range s.values {
|
||||
out.values[field] = struct{}{}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrHashBackendRequired 表示 hash 后端缺失或没有完成初始化。
|
||||
var ErrHashBackendRequired = errors.New("hash backend is required")
|
||||
|
||||
// HashBackend 是按记录主键和字段名读写二进制字段的后端接口。
|
||||
type HashBackend interface {
|
||||
LoadHash(context.Context, string, []string) (map[string][]byte, bool, error)
|
||||
SaveHash(context.Context, string, map[string][]byte, time.Duration) error
|
||||
}
|
||||
|
||||
// HashSaveBatch 表示一次 hash 字段批量保存项。
|
||||
type HashSaveBatch struct {
|
||||
Key string
|
||||
Fields map[string][]byte
|
||||
TTL time.Duration
|
||||
}
|
||||
|
||||
// HashBatchBackend 支持批量保存 hash 字段。
|
||||
type HashBatchBackend interface {
|
||||
SaveHashBatch(context.Context, []HashSaveBatch) error
|
||||
}
|
||||
|
||||
// HashLoadRequest 描述一次 hash 批量读取请求。
|
||||
type HashLoadRequest struct {
|
||||
Key string
|
||||
Fields []string
|
||||
}
|
||||
|
||||
// HashLoadResult 描述一次 hash 批量读取结果。
|
||||
type HashLoadResult struct {
|
||||
Fields map[string][]byte
|
||||
Found bool
|
||||
}
|
||||
|
||||
// HashBatchLoaderBackend 支持批量读取 hash 字段。
|
||||
type HashBatchLoaderBackend interface {
|
||||
LoadHashBatch(context.Context, []HashLoadRequest) (map[string]HashLoadResult, error)
|
||||
}
|
||||
|
||||
// MemoryHashBackend 是线程安全的内存 hash 后端。
|
||||
type MemoryHashBackend struct {
|
||||
mu sync.RWMutex
|
||||
records map[string]map[string][]byte
|
||||
ttl map[string]time.Duration
|
||||
}
|
||||
|
||||
// NewMemoryHashBackend 创建内存 hash 后端。
|
||||
func NewMemoryHashBackend() *MemoryHashBackend {
|
||||
return &MemoryHashBackend{
|
||||
records: make(map[string]map[string][]byte),
|
||||
ttl: make(map[string]time.Duration),
|
||||
}
|
||||
}
|
||||
|
||||
// LoadHash 从内存读取指定 hash 字段。
|
||||
func (b *MemoryHashBackend) LoadHash(ctx context.Context, key string, fields []string) (map[string][]byte, bool, error) {
|
||||
if b == nil {
|
||||
return nil, false, ErrHashBackendRequired
|
||||
}
|
||||
ctx = contextOrBackground(ctx)
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
return nil, false, ErrRecordKeyRequired
|
||||
}
|
||||
fields = normHashFields(fields)
|
||||
|
||||
b.mu.RLock()
|
||||
record, ok := b.records[key]
|
||||
if !ok {
|
||||
b.mu.RUnlock()
|
||||
return nil, false, nil
|
||||
}
|
||||
out := make(map[string][]byte, len(record))
|
||||
if len(fields) == 0 {
|
||||
for name, data := range record {
|
||||
out[name] = append([]byte(nil), data...)
|
||||
}
|
||||
} else {
|
||||
for _, name := range fields {
|
||||
if data, exists := record[name]; exists {
|
||||
out[name] = append([]byte(nil), data...)
|
||||
}
|
||||
}
|
||||
}
|
||||
b.mu.RUnlock()
|
||||
return out, true, nil
|
||||
}
|
||||
|
||||
// LoadHashBatch 批量读取 hash 字段。
|
||||
func (b *MemoryHashBackend) LoadHashBatch(ctx context.Context, requests []HashLoadRequest) (map[string]HashLoadResult, error) {
|
||||
if b == nil {
|
||||
return nil, ErrHashBackendRequired
|
||||
}
|
||||
ctx = contextOrBackground(ctx)
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make(map[string]HashLoadResult, len(requests))
|
||||
for _, request := range requests {
|
||||
fields, found, err := b.LoadHash(ctx, request.Key, request.Fields)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[strings.TrimSpace(request.Key)] = HashLoadResult{Fields: fields, Found: found}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SaveHash 保存指定 hash 字段。
|
||||
func (b *MemoryHashBackend) SaveHash(ctx context.Context, key string, fields map[string][]byte, ttl time.Duration) error {
|
||||
if b == nil {
|
||||
return ErrHashBackendRequired
|
||||
}
|
||||
ctx = contextOrBackground(ctx)
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
return ErrRecordKeyRequired
|
||||
}
|
||||
fields, err := cloneHashFields(fields)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(fields) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
b.mu.Lock()
|
||||
b.ensureReadyLocked()
|
||||
record := b.records[key]
|
||||
if record == nil {
|
||||
record = make(map[string][]byte, len(fields))
|
||||
b.records[key] = record
|
||||
}
|
||||
for name, data := range fields {
|
||||
record[name] = append([]byte(nil), data...)
|
||||
}
|
||||
if ttl > 0 {
|
||||
b.ttl[key] = ttl
|
||||
}
|
||||
b.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// SaveHashBatch 批量保存 hash 字段。
|
||||
func (b *MemoryHashBackend) SaveHashBatch(ctx context.Context, batches []HashSaveBatch) error {
|
||||
if b == nil {
|
||||
return ErrHashBackendRequired
|
||||
}
|
||||
ctx = contextOrBackground(ctx)
|
||||
merged, err := mergeHashSaveBatches(batches)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, batch := range merged {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := b.SaveHash(ctx, batch.Key, batch.Fields, batch.TTL); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func mergeHashSaveBatches(batches []HashSaveBatch) ([]HashSaveBatch, error) {
|
||||
if len(batches) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
merged := make(map[string]HashSaveBatch, len(batches))
|
||||
for _, batch := range batches {
|
||||
if len(batch.Fields) == 0 {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSpace(batch.Key)
|
||||
if key == "" {
|
||||
return nil, ErrRecordKeyRequired
|
||||
}
|
||||
fields, err := cloneHashFields(batch.Fields)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(fields) == 0 {
|
||||
continue
|
||||
}
|
||||
current := merged[key]
|
||||
if current.Fields == nil {
|
||||
current = HashSaveBatch{
|
||||
Key: key,
|
||||
Fields: make(map[string][]byte, len(fields)),
|
||||
}
|
||||
}
|
||||
for name, data := range fields {
|
||||
current.Fields[name] = append([]byte(nil), data...)
|
||||
}
|
||||
current.TTL = batch.TTL
|
||||
merged[key] = current
|
||||
}
|
||||
keys := make([]string, 0, len(merged))
|
||||
for key := range merged {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
out := make([]HashSaveBatch, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
batch := merged[key]
|
||||
batch.Fields = cloneBytesMap(batch.Fields)
|
||||
out = append(out, batch)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Check 检查上下文是否已取消。
|
||||
func (b *MemoryHashBackend) Check(ctx context.Context) error {
|
||||
if b == nil {
|
||||
return ErrHashBackendRequired
|
||||
}
|
||||
ctx = contextOrBackground(ctx)
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
// Snapshot 返回全部 hash 记录副本。
|
||||
func (b *MemoryHashBackend) Snapshot() map[string]map[string][]byte {
|
||||
if b == nil {
|
||||
return map[string]map[string][]byte{}
|
||||
}
|
||||
b.mu.RLock()
|
||||
out := make(map[string]map[string][]byte, len(b.records))
|
||||
for key, fields := range b.records {
|
||||
out[key] = cloneBytesMap(fields)
|
||||
}
|
||||
b.mu.RUnlock()
|
||||
return out
|
||||
}
|
||||
|
||||
func (b *MemoryHashBackend) ensureReadyLocked() {
|
||||
if b.records == nil {
|
||||
b.records = make(map[string]map[string][]byte)
|
||||
}
|
||||
if b.ttl == nil {
|
||||
b.ttl = make(map[string]time.Duration)
|
||||
}
|
||||
}
|
||||
|
||||
func cloneHashFields(fields map[string][]byte) (map[string][]byte, error) {
|
||||
if len(fields) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
out := make(map[string][]byte, len(fields))
|
||||
names := make([]string, 0, len(fields))
|
||||
for rawName := range fields {
|
||||
names = append(names, rawName)
|
||||
}
|
||||
sort.Strings(names)
|
||||
for _, rawName := range names {
|
||||
name := strings.TrimSpace(rawName)
|
||||
if name == "" {
|
||||
return nil, ErrRecordKeyRequired
|
||||
}
|
||||
if _, exists := out[name]; exists {
|
||||
continue
|
||||
}
|
||||
data := fields[rawName]
|
||||
if data == nil {
|
||||
return nil, errors.New("hash field value is nil")
|
||||
}
|
||||
out[name] = append([]byte(nil), data...)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func cloneBytesMap(in map[string][]byte) map[string][]byte {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string][]byte, len(in))
|
||||
for key, data := range in {
|
||||
out[key] = append([]byte(nil), data...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func normHashFields(fields []string) []string {
|
||||
if len(fields) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[string]struct{}, len(fields))
|
||||
for _, field := range fields {
|
||||
field = strings.TrimSpace(field)
|
||||
if field != "" {
|
||||
seen[field] = struct{}{}
|
||||
}
|
||||
}
|
||||
out := make([]string, 0, len(seen))
|
||||
for field := range seen {
|
||||
out = append(out, field)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// JSONFileStoreOptions 配置 JSON 文件记录存储。
|
||||
type JSONFileStoreOptions[T any] struct {
|
||||
Directory string
|
||||
DefaultDirectory string
|
||||
Key KeyFunc[T]
|
||||
Clone CloneFunc[T]
|
||||
WithLoadedKey func(T, string) T
|
||||
}
|
||||
|
||||
// JSONFileStore 用单记录 JSON 文件实现 Store。
|
||||
type JSONFileStore[T any] struct {
|
||||
mu sync.RWMutex
|
||||
dir string
|
||||
keyFn KeyFunc[T]
|
||||
cloneFn CloneFunc[T]
|
||||
withLoadedKey func(T, string) T
|
||||
}
|
||||
|
||||
// NewJSONFileStore 创建 JSON 文件记录存储。
|
||||
func NewJSONFileStore[T any](options JSONFileStoreOptions[T]) *JSONFileStore[T] {
|
||||
dir := strings.TrimSpace(options.Directory)
|
||||
if dir == "" {
|
||||
dir = strings.TrimSpace(options.DefaultDirectory)
|
||||
}
|
||||
if dir == "" {
|
||||
dir = "data/records"
|
||||
}
|
||||
cloneFn := options.Clone
|
||||
if cloneFn == nil {
|
||||
cloneFn = IdentityClone[T]
|
||||
}
|
||||
return &JSONFileStore[T]{
|
||||
dir: dir,
|
||||
keyFn: options.Key,
|
||||
cloneFn: cloneFn,
|
||||
withLoadedKey: options.WithLoadedKey,
|
||||
}
|
||||
}
|
||||
|
||||
// Load 从 JSON 文件读取记录。
|
||||
func (s *JSONFileStore[T]) Load(ctx context.Context, key string) (T, bool, error) {
|
||||
ctx = contextOrBackground(ctx)
|
||||
var zero T
|
||||
if err := ctx.Err(); err != nil {
|
||||
return zero, false, err
|
||||
}
|
||||
path, err := s.recordPath(key)
|
||||
if err != nil {
|
||||
return zero, false, err
|
||||
}
|
||||
s.mu.RLock()
|
||||
data, err := os.ReadFile(path) //nolint:gosec // G304:路径来自框架配置、仓库扫描或测试临时目录,调用点负责限定输入范围。
|
||||
s.mu.RUnlock()
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return zero, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return zero, false, err
|
||||
}
|
||||
var record T
|
||||
if err := json.Unmarshal(data, &record); err != nil {
|
||||
return zero, false, fmt.Errorf("decode record %s: %w", key, err)
|
||||
}
|
||||
if s.withLoadedKey != nil {
|
||||
record = s.withLoadedKey(record, key)
|
||||
}
|
||||
return s.cloneFn(record), true, nil
|
||||
}
|
||||
|
||||
// Check 检查记录目录是否可写。
|
||||
func (s *JSONFileStore[T]) Check(ctx context.Context) error {
|
||||
return checkWritableDir(ctx, s.dir)
|
||||
}
|
||||
|
||||
// Save 原子写入记录 JSON 文件。
|
||||
func (s *JSONFileStore[T]) Save(ctx context.Context, record T) error {
|
||||
ctx = contextOrBackground(ctx)
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
key, err := RecordKey(s.keyFn, record)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path, err := s.recordPath(key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.MarshalIndent(record, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
return WriteFileAtomically(ctx, path, data, 0o600)
|
||||
}
|
||||
|
||||
func (s *JSONFileStore[T]) recordPath(key string) (string, error) {
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
return "", ErrRecordKeyRequired
|
||||
}
|
||||
name := base64.RawURLEncoding.EncodeToString([]byte(key)) + ".json"
|
||||
return filepath.Join(s.dir, name), nil
|
||||
}
|
||||
|
||||
func checkWritableDir(ctx context.Context, dir string) error {
|
||||
ctx = contextOrBackground(ctx)
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp, err := os.CreateTemp(dir, ".preflight-*.tmp")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
if err := tmp.Close(); err != nil {
|
||||
_ = os.Remove(tmpName)
|
||||
return err
|
||||
}
|
||||
if err := os.Remove(tmpName); err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Err()
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// MemoryStore 是线程安全的内存记录存储。
|
||||
type MemoryStore[T any] struct {
|
||||
mu sync.RWMutex
|
||||
records map[string]T
|
||||
keyFn KeyFunc[T]
|
||||
cloneFn CloneFunc[T]
|
||||
}
|
||||
|
||||
// NewMemoryStore 创建内存记录存储。
|
||||
func NewMemoryStore[T any](keyFn KeyFunc[T], cloneFn CloneFunc[T]) *MemoryStore[T] {
|
||||
if cloneFn == nil {
|
||||
cloneFn = IdentityClone[T]
|
||||
}
|
||||
return &MemoryStore[T]{
|
||||
records: make(map[string]T),
|
||||
keyFn: keyFn,
|
||||
cloneFn: cloneFn,
|
||||
}
|
||||
}
|
||||
|
||||
// Load 从内存按主键读取记录。
|
||||
func (s *MemoryStore[T]) Load(_ context.Context, key string) (T, bool, error) {
|
||||
s.mu.RLock()
|
||||
record, ok := s.records[key]
|
||||
s.mu.RUnlock()
|
||||
if !ok {
|
||||
var zero T
|
||||
return zero, false, nil
|
||||
}
|
||||
return s.cloneFn(record), true, nil
|
||||
}
|
||||
|
||||
// Check 检查上下文是否已取消。
|
||||
func (s *MemoryStore[T]) Check(ctx context.Context) error {
|
||||
ctx = contextOrBackground(ctx)
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
// Save 保存记录副本到内存。
|
||||
func (s *MemoryStore[T]) Save(_ context.Context, record T) error {
|
||||
key, err := RecordKey(s.keyFn, record)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.records[key] = s.cloneFn(record)
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete removes one record. It is primarily used by in-memory transactional
|
||||
// tests to restore an aggregate that did not exist before a failed commit.
|
||||
func (s *MemoryStore[T]) Delete(ctx context.Context, key string) error {
|
||||
ctx = contextOrBackground(ctx)
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
return ErrRecordKeyRequired
|
||||
}
|
||||
s.mu.Lock()
|
||||
delete(s.records, key)
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Snapshot 返回全部记录副本,可按 less 排序。
|
||||
func (s *MemoryStore[T]) Snapshot(less func(T, T) bool) []T {
|
||||
s.mu.RLock()
|
||||
out := make([]T, 0, len(s.records))
|
||||
for _, record := range s.records {
|
||||
out = append(out, s.cloneFn(record))
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
if less != nil {
|
||||
sortRecords(out, less)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func sortRecords[T any](records []T, less func(T, T) bool) {
|
||||
for i := 1; i < len(records); i++ {
|
||||
for j := i; j > 0 && less(records[j], records[j-1]); j-- {
|
||||
records[j], records[j-1] = records[j-1], records[j]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrObjectCodecReq 表示对象字段编解码器缺失或不合法。
|
||||
var ErrObjectCodecReq = errors.New("object field codec is required")
|
||||
|
||||
// ErrHashObjectStoreReq 表示 hash 对象存储本身缺失。
|
||||
var ErrHashObjectStoreReq = errors.New("hash object store is required")
|
||||
|
||||
// ErrObjectRecordFactoryReq 表示对象记录构造函数缺失。
|
||||
var ErrObjectRecordFactoryReq = errors.New("hash object new record function is required")
|
||||
|
||||
// ObjectFieldCodec 描述对象字段和 hash 字段之间的编解码规则。
|
||||
type ObjectFieldCodec[T any, F comparable] struct {
|
||||
Field F
|
||||
Name string
|
||||
Marshal func(T) ([]byte, error)
|
||||
Apply func(*T, []byte) error
|
||||
}
|
||||
|
||||
// HashObjectStoreOptions 配置 hash 后端对象存储。
|
||||
type HashObjectStoreOptions[T any, F comparable] struct {
|
||||
Backend HashBackend
|
||||
Fields FieldRegistry[F]
|
||||
Codecs []ObjectFieldCodec[T, F]
|
||||
Key KeyFunc[T]
|
||||
Clone CloneFunc[T]
|
||||
NewRecord func(string) T
|
||||
NormalizeKey func(string) string
|
||||
TTL time.Duration
|
||||
}
|
||||
|
||||
// HashObjectStore 把对象字段拆分保存到 HashBackend。
|
||||
type HashObjectStore[T any, F comparable] struct {
|
||||
backend HashBackend
|
||||
fields FieldRegistry[F]
|
||||
codecs map[F]objectFieldCodec[T]
|
||||
keyFn KeyFunc[T]
|
||||
cloneFn CloneFunc[T]
|
||||
newRecord func(string) T
|
||||
normalizeKey func(string) string
|
||||
ttl time.Duration
|
||||
}
|
||||
|
||||
type objectFieldCodec[T any] struct {
|
||||
name string
|
||||
marshal func(T) ([]byte, error)
|
||||
apply func(*T, []byte) error
|
||||
}
|
||||
|
||||
// NewHashObjectStore 创建 hash 对象存储。
|
||||
func NewHashObjectStore[T any, F comparable](options HashObjectStoreOptions[T, F]) (*HashObjectStore[T, F], error) {
|
||||
if options.Backend == nil {
|
||||
return nil, ErrHashBackendRequired
|
||||
}
|
||||
if options.Key == nil {
|
||||
return nil, fmt.Errorf("%w: key function is nil", ErrRecordKeyRequired)
|
||||
}
|
||||
if options.NewRecord == nil {
|
||||
return nil, ErrObjectRecordFactoryReq
|
||||
}
|
||||
if options.Clone == nil {
|
||||
options.Clone = IdentityClone[T]
|
||||
}
|
||||
fields := options.Fields
|
||||
if len(fields.All()) == 0 && len(options.Codecs) > 0 {
|
||||
order := make([]F, 0, len(options.Codecs))
|
||||
for _, codec := range options.Codecs {
|
||||
order = append(order, codec.Field)
|
||||
}
|
||||
fields = NewFieldRegistry(order)
|
||||
}
|
||||
codecs, err := normObjCodecs(fields, options.Codecs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &HashObjectStore[T, F]{
|
||||
backend: options.Backend,
|
||||
fields: fields,
|
||||
codecs: codecs,
|
||||
keyFn: options.Key,
|
||||
cloneFn: options.Clone,
|
||||
newRecord: options.NewRecord,
|
||||
normalizeKey: options.NormalizeKey,
|
||||
ttl: options.TTL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Load 从 hash 后端读取并组装对象。
|
||||
func (s *HashObjectStore[T, F]) Load(ctx context.Context, key string) (T, bool, error) {
|
||||
var zero T
|
||||
if err := s.ensureLoadReady(); err != nil {
|
||||
return zero, false, err
|
||||
}
|
||||
ctx = contextOrBackground(ctx)
|
||||
key = normalizeLookupKey(key, s.normalizeKey)
|
||||
if key == "" {
|
||||
return zero, false, ErrRecordKeyRequired
|
||||
}
|
||||
raw, found, err := s.backend.LoadHash(ctx, key, s.fieldNames(s.fields.All()))
|
||||
if err != nil || !found {
|
||||
return zero, found, err
|
||||
}
|
||||
record, err := s.decodeRecord(key, raw)
|
||||
if err != nil {
|
||||
return zero, false, err
|
||||
}
|
||||
return s.cloneFn(record), true, nil
|
||||
}
|
||||
|
||||
// LoadBatch 批量读取并组装对象。
|
||||
func (s *HashObjectStore[T, F]) LoadBatch(ctx context.Context, keys []string) (map[string]T, error) {
|
||||
if err := s.ensureLoadReady(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ctx = contextOrBackground(ctx)
|
||||
keys = normalizeBatchKeys(keys, s.normalizeKey)
|
||||
if len(keys) == 0 {
|
||||
return map[string]T{}, ctx.Err()
|
||||
}
|
||||
if batcher, ok := s.backend.(HashBatchLoaderBackend); ok {
|
||||
fieldNames := s.fieldNames(s.fields.All())
|
||||
requests := make([]HashLoadRequest, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
requests = append(requests, HashLoadRequest{
|
||||
Key: key,
|
||||
Fields: fieldNames,
|
||||
})
|
||||
}
|
||||
results, err := batcher.LoadHashBatch(ctx, requests)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make(map[string]T, len(results))
|
||||
for _, key := range keys {
|
||||
result := results[key]
|
||||
if !result.Found {
|
||||
continue
|
||||
}
|
||||
record, err := s.decodeRecord(key, result.Fields)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[key] = s.cloneFn(record)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
out := make(map[string]T, len(keys))
|
||||
for _, key := range keys {
|
||||
record, ok, err := s.Load(ctx, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ok {
|
||||
out[key] = record
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Save 保存对象的全部字段。
|
||||
func (s *HashObjectStore[T, F]) Save(ctx context.Context, record T) error {
|
||||
if err := s.ensureSaveReady(); err != nil {
|
||||
return err
|
||||
}
|
||||
fields := s.fields.All()
|
||||
if len(fields) == 0 {
|
||||
return ErrAllFieldsRequired
|
||||
}
|
||||
return s.SaveFields(ctx, record, fields...)
|
||||
}
|
||||
|
||||
// SaveFields 保存对象的指定字段。
|
||||
func (s *HashObjectStore[T, F]) SaveFields(ctx context.Context, record T, fields ...F) error {
|
||||
if err := s.ensureSaveReady(); err != nil {
|
||||
return err
|
||||
}
|
||||
ctx = contextOrBackground(ctx)
|
||||
fields = s.fields.Normalize(fields)
|
||||
if len(fields) == 0 {
|
||||
return nil
|
||||
}
|
||||
key, err := s.recordKey(record)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hashFields, err := s.encodeFields(record, fields)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.backend.SaveHash(ctx, key, hashFields, s.ttl)
|
||||
}
|
||||
|
||||
// SaveFieldBatch 批量保存对象字段。
|
||||
func (s *HashObjectStore[T, F]) SaveFieldBatch(ctx context.Context, saves []FieldSave[T, F]) error {
|
||||
if err := s.ensureSaveReady(); err != nil {
|
||||
return err
|
||||
}
|
||||
ctx = contextOrBackground(ctx)
|
||||
batches := make([]HashSaveBatch, 0, len(saves))
|
||||
for _, save := range normalizeFieldSaves(s.fields.Normalize, saves) {
|
||||
key, err := s.recordKey(save.Record)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fields, err := s.encodeFields(save.Record, save.Fields)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(fields) == 0 {
|
||||
continue
|
||||
}
|
||||
batches = append(batches, HashSaveBatch{Key: key, Fields: fields, TTL: s.ttl})
|
||||
}
|
||||
var err error
|
||||
batches, err = mergeHashSaveBatches(batches)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(batches) == 0 {
|
||||
return nil
|
||||
}
|
||||
if batcher, ok := s.backend.(HashBatchBackend); ok {
|
||||
return batcher.SaveHashBatch(ctx, batches)
|
||||
}
|
||||
for _, batch := range batches {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.backend.SaveHash(ctx, batch.Key, batch.Fields, batch.TTL); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check 检查 hash 后端可用性。
|
||||
func (s *HashObjectStore[T, F]) Check(ctx context.Context) error {
|
||||
if err := s.ensureBaseReady(); err != nil {
|
||||
return err
|
||||
}
|
||||
return Check(ctx, s.backend)
|
||||
}
|
||||
|
||||
// Fields 返回对象存储支持的字段列表。
|
||||
func (s *HashObjectStore[T, F]) Fields() []F {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
return s.fields.All()
|
||||
}
|
||||
|
||||
// FieldNames 返回字段对应的 hash 字段名。
|
||||
func (s *HashObjectStore[T, F]) FieldNames(fields ...F) []string {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
if len(fields) == 0 {
|
||||
return s.fieldNames(s.fields.All())
|
||||
}
|
||||
return s.fieldNames(s.fields.Normalize(fields))
|
||||
}
|
||||
|
||||
func (s *HashObjectStore[T, F]) recordKey(record T) (string, error) {
|
||||
key, err := RecordKey(s.keyFn, record)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
key = normalizeLookupKey(key, s.normalizeKey)
|
||||
if key == "" {
|
||||
return "", ErrRecordKeyRequired
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
func (s *HashObjectStore[T, F]) ensureBaseReady() error {
|
||||
if s == nil {
|
||||
return ErrHashObjectStoreReq
|
||||
}
|
||||
if s.backend == nil {
|
||||
return ErrHashBackendRequired
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *HashObjectStore[T, F]) ensureLoadReady() error {
|
||||
if err := s.ensureBaseReady(); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.newRecord == nil {
|
||||
return ErrObjectRecordFactoryReq
|
||||
}
|
||||
s.ensureClone()
|
||||
fields := s.fields.All()
|
||||
if len(fields) == 0 {
|
||||
return ErrAllFieldsRequired
|
||||
}
|
||||
return s.ensureCodecs(fields, false, true)
|
||||
}
|
||||
|
||||
func (s *HashObjectStore[T, F]) ensureSaveReady() error {
|
||||
if err := s.ensureBaseReady(); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.keyFn == nil {
|
||||
return fmt.Errorf("%w: key function is nil", ErrRecordKeyRequired)
|
||||
}
|
||||
s.ensureClone()
|
||||
fields := s.fields.All()
|
||||
if len(fields) == 0 {
|
||||
return ErrAllFieldsRequired
|
||||
}
|
||||
return s.ensureCodecs(fields, true, false)
|
||||
}
|
||||
|
||||
func (s *HashObjectStore[T, F]) ensureClone() {
|
||||
if s.cloneFn == nil {
|
||||
s.cloneFn = IdentityClone[T]
|
||||
}
|
||||
}
|
||||
|
||||
func (s *HashObjectStore[T, F]) ensureCodecs(fields []F, requireMarshal bool, requireApply bool) error {
|
||||
for _, field := range fields {
|
||||
codec, ok := s.codecs[field]
|
||||
if !ok || strings.TrimSpace(codec.name) == "" {
|
||||
return fmt.Errorf("%w: %v", ErrObjectCodecReq, field)
|
||||
}
|
||||
if requireMarshal && codec.marshal == nil {
|
||||
return fmt.Errorf("%w: %v", ErrObjectCodecReq, field)
|
||||
}
|
||||
if requireApply && codec.apply == nil {
|
||||
return fmt.Errorf("%w: %v", ErrObjectCodecReq, field)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *HashObjectStore[T, F]) encodeFields(record T, fields []F) (map[string][]byte, error) {
|
||||
out := make(map[string][]byte, len(fields))
|
||||
for _, field := range fields {
|
||||
codec, ok := s.codecs[field]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: %v", ErrObjectCodecReq, field)
|
||||
}
|
||||
data, err := codec.marshal(s.cloneFn(record))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal object field %q: %w", codec.name, err)
|
||||
}
|
||||
if data == nil {
|
||||
return nil, fmt.Errorf("marshal object field %q returned nil", codec.name)
|
||||
}
|
||||
out[codec.name] = append([]byte(nil), data...)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *HashObjectStore[T, F]) decodeRecord(key string, raw map[string][]byte) (T, error) {
|
||||
record := s.newRecord(key)
|
||||
for _, field := range s.fields.All() {
|
||||
codec := s.codecs[field]
|
||||
data, ok := raw[codec.name]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if err := codec.apply(&record, append([]byte(nil), data...)); err != nil {
|
||||
return record, fmt.Errorf("apply object field %q: %w", codec.name, err)
|
||||
}
|
||||
}
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func (s *HashObjectStore[T, F]) fieldNames(fields []F) []string {
|
||||
if len(fields) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(fields))
|
||||
for _, field := range fields {
|
||||
if codec, ok := s.codecs[field]; ok {
|
||||
out = append(out, codec.name)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func normObjCodecs[T any, F comparable](fields FieldRegistry[F], codecs []ObjectFieldCodec[T, F]) (map[F]objectFieldCodec[T], error) {
|
||||
if len(fields.All()) == 0 {
|
||||
return nil, ErrAllFieldsRequired
|
||||
}
|
||||
out := make(map[F]objectFieldCodec[T], len(codecs))
|
||||
names := make(map[string]F, len(codecs))
|
||||
seenFields := make(map[F]struct{}, len(codecs))
|
||||
for _, codec := range codecs {
|
||||
if !fields.IsKnown(codec.Field) {
|
||||
return nil, fmt.Errorf("%w: unknown field %v", ErrObjectCodecReq, codec.Field)
|
||||
}
|
||||
if _, exists := seenFields[codec.Field]; exists {
|
||||
return nil, fmt.Errorf("duplicate object field codec: %v", codec.Field)
|
||||
}
|
||||
seenFields[codec.Field] = struct{}{}
|
||||
name := strings.TrimSpace(codec.Name)
|
||||
if name == "" || codec.Marshal == nil || codec.Apply == nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrObjectCodecReq, codec.Field)
|
||||
}
|
||||
if existing, exists := names[name]; exists && existing != codec.Field {
|
||||
return nil, fmt.Errorf("duplicate object field name %q", name)
|
||||
}
|
||||
names[name] = codec.Field
|
||||
out[codec.Field] = objectFieldCodec[T]{
|
||||
name: name,
|
||||
marshal: codec.Marshal,
|
||||
apply: codec.Apply,
|
||||
}
|
||||
}
|
||||
for _, field := range fields.All() {
|
||||
if _, ok := out[field]; !ok {
|
||||
return nil, fmt.Errorf("%w: %v", ErrObjectCodecReq, field)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/gomodule/redigo/redis"
|
||||
)
|
||||
|
||||
// ErrRedisExecutorClosed 表示 Redis 执行器已关闭或未初始化。
|
||||
var ErrRedisExecutorClosed = errors.New("redis executor is closed")
|
||||
|
||||
// closeRedisConnErr 在 Redis 操作本身成功时保留连接关闭错误,避免池连接异常被静默吞掉。
|
||||
func closeRedisConnErr(conn redis.Conn, err *error) {
|
||||
if conn == nil || err == nil {
|
||||
return
|
||||
}
|
||||
if closeErr := conn.Close(); closeErr != nil && *err == nil {
|
||||
*err = closeErr
|
||||
}
|
||||
}
|
||||
|
||||
// RedigoOptions 配置 redigo 连接池。
|
||||
type RedigoOptions struct {
|
||||
Address string
|
||||
Password string
|
||||
DB int
|
||||
PoolSize int
|
||||
MaxActive int
|
||||
IdleTimeout time.Duration
|
||||
Timeout time.Duration
|
||||
ConnectTimeout time.Duration
|
||||
ReadTimeout time.Duration
|
||||
WriteTimeout time.Duration
|
||||
TestOnBorrowAfter time.Duration
|
||||
}
|
||||
|
||||
// RedisPoolStats 描述 Redis 连接池状态。
|
||||
type RedisPoolStats struct {
|
||||
Address string `json:"address"`
|
||||
DB int `json:"db"`
|
||||
MaxIdle int `json:"max_idle"`
|
||||
MaxActive int `json:"max_active"`
|
||||
IdleTimeout time.Duration `json:"idle_timeout"`
|
||||
ActiveCount int `json:"active_count"`
|
||||
IdleCount int `json:"idle_count"`
|
||||
WaitCount int64 `json:"wait_count"`
|
||||
WaitDuration time.Duration `json:"wait_duration"`
|
||||
}
|
||||
|
||||
// RedigoExecutor 用 redigo 连接池实现 RedisExecutor。
|
||||
type RedigoExecutor struct {
|
||||
options RedigoOptions
|
||||
pool *redis.Pool
|
||||
}
|
||||
|
||||
// NormalizeRedigoOptions 补齐 redigo 默认连接参数。
|
||||
func NormalizeRedigoOptions(options RedigoOptions) RedigoOptions {
|
||||
if options.Address == "" {
|
||||
options.Address = "127.0.0.1:6379"
|
||||
}
|
||||
if options.PoolSize <= 0 {
|
||||
options.PoolSize = 8
|
||||
}
|
||||
if options.MaxActive <= 0 {
|
||||
options.MaxActive = options.PoolSize * 2
|
||||
}
|
||||
if options.IdleTimeout <= 0 {
|
||||
options.IdleTimeout = time.Minute
|
||||
}
|
||||
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.TestOnBorrowAfter < 0 {
|
||||
options.TestOnBorrowAfter = 0
|
||||
}
|
||||
if options.TestOnBorrowAfter == 0 {
|
||||
options.TestOnBorrowAfter = time.Minute
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
// NewRedigoExecutor 创建 redigo Redis 执行器。
|
||||
func NewRedigoExecutor(options RedigoOptions) *RedigoExecutor {
|
||||
options = NormalizeRedigoOptions(options)
|
||||
return &RedigoExecutor{options: options, pool: &redis.Pool{
|
||||
MaxIdle: options.PoolSize,
|
||||
MaxActive: options.MaxActive,
|
||||
IdleTimeout: options.IdleTimeout,
|
||||
Wait: true,
|
||||
Dial: func() (redis.Conn, error) {
|
||||
dialOptions := []redis.DialOption{
|
||||
redis.DialConnectTimeout(options.ConnectTimeout),
|
||||
redis.DialReadTimeout(options.ReadTimeout),
|
||||
redis.DialWriteTimeout(options.WriteTimeout),
|
||||
redis.DialDatabase(options.DB),
|
||||
}
|
||||
if options.Password != "" {
|
||||
dialOptions = append(dialOptions, redis.DialPassword(options.Password))
|
||||
}
|
||||
return redis.Dial("tcp", options.Address, dialOptions...)
|
||||
},
|
||||
TestOnBorrow: func(conn redis.Conn, lastUsed time.Time) error {
|
||||
if time.Since(lastUsed) < options.TestOnBorrowAfter {
|
||||
return nil
|
||||
}
|
||||
_, err := conn.Do("PING")
|
||||
return err
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
// Do 执行单条 Redis 命令。
|
||||
func (e *RedigoExecutor) Do(ctx context.Context, command string, args ...any) (reply any, err error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if e == nil || e.pool == nil {
|
||||
return nil, ErrRedisExecutorClosed
|
||||
}
|
||||
conn, err := e.pool.GetContext(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer closeRedisConnErr(conn, &err)
|
||||
return conn.Do(command, args...)
|
||||
}
|
||||
|
||||
// DoBatch 使用同一连接批量发送 Redis 命令。
|
||||
func (e *RedigoExecutor) DoBatch(ctx context.Context, commands []RedisCommand) (err error) {
|
||||
if len(commands) == 0 {
|
||||
return nil
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if e == nil || e.pool == nil {
|
||||
return ErrRedisExecutorClosed
|
||||
}
|
||||
conn, err := e.pool.GetContext(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer closeRedisConnErr(conn, &err)
|
||||
return RunRedigoBatch(ctx, conn, commands)
|
||||
}
|
||||
|
||||
// RunRedigoBatch 在已获取的 redigo 连接上发送批量命令,并在 Flush 后完整接收响应。
|
||||
func RunRedigoBatch(ctx context.Context, conn redis.Conn, commands []RedisCommand) error {
|
||||
if len(commands) == 0 {
|
||||
return nil
|
||||
}
|
||||
if conn == nil {
|
||||
return ErrRedisExecutorClosed
|
||||
}
|
||||
sent := 0
|
||||
for _, command := range commands {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if command.Name == "" {
|
||||
continue
|
||||
}
|
||||
if err := conn.Send(command.Name, command.Args...); err != nil {
|
||||
return err
|
||||
}
|
||||
sent++
|
||||
}
|
||||
if sent == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := conn.Flush(); err != nil {
|
||||
return err
|
||||
}
|
||||
// Flush 成功后命令可能已经被 Redis 执行;此时必须排空响应,不能再让调用方 ctx
|
||||
// 中途打断 Receive,否则异步刷盘会把已提交写误判成失败并重复入队。
|
||||
for idx := 0; idx < sent; idx++ {
|
||||
if _, err := conn.Receive(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PoolStats 返回连接池统计信息。
|
||||
func (e *RedigoExecutor) PoolStats() RedisPoolStats {
|
||||
if e == nil || e.pool == nil {
|
||||
return RedisPoolStats{}
|
||||
}
|
||||
stats := e.pool.Stats()
|
||||
return RedisPoolStats{
|
||||
Address: e.options.Address,
|
||||
DB: e.options.DB,
|
||||
MaxIdle: e.options.PoolSize,
|
||||
MaxActive: e.options.MaxActive,
|
||||
IdleTimeout: e.options.IdleTimeout,
|
||||
ActiveCount: stats.ActiveCount,
|
||||
IdleCount: stats.IdleCount,
|
||||
WaitCount: stats.WaitCount,
|
||||
WaitDuration: stats.WaitDuration,
|
||||
}
|
||||
}
|
||||
|
||||
// Close 关闭 Redis 连接池。
|
||||
func (e *RedigoExecutor) Close() error {
|
||||
if e == nil || e.pool == nil {
|
||||
return nil
|
||||
}
|
||||
return e.pool.Close()
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RedisExecutor 是最小 Redis 命令执行接口。
|
||||
type RedisExecutor interface {
|
||||
Do(context.Context, string, ...any) (any, error)
|
||||
}
|
||||
|
||||
// RedisBatchExecutor 支持批量发送 Redis 命令。
|
||||
type RedisBatchExecutor interface {
|
||||
DoBatch(context.Context, []RedisCommand) error
|
||||
}
|
||||
|
||||
// RedisCommand 描述一条 Redis 命令。
|
||||
type RedisCommand struct {
|
||||
Name string
|
||||
Args []any
|
||||
}
|
||||
|
||||
// RedisHashBatch 描述一次 Redis hash 字段保存项。
|
||||
type RedisHashBatch struct {
|
||||
Key string
|
||||
Fields map[string][]byte
|
||||
TTL time.Duration
|
||||
}
|
||||
|
||||
// SaveRedisHashFields 保存单个 Redis hash 的字段。
|
||||
func SaveRedisHashFields(ctx context.Context, executor RedisExecutor, key string, fields map[string][]byte, ttl time.Duration) error {
|
||||
return SaveRedisHashFieldBatches(ctx, executor, []RedisHashBatch{{
|
||||
Key: key,
|
||||
Fields: fields,
|
||||
TTL: ttl,
|
||||
}})
|
||||
}
|
||||
|
||||
// SaveRedisHashFieldBatches 批量保存 Redis hash 字段。
|
||||
func SaveRedisHashFieldBatches(ctx context.Context, executor RedisExecutor, batches []RedisHashBatch) error {
|
||||
if executor == nil {
|
||||
return errors.New("redis executor is nil")
|
||||
}
|
||||
commands, err := redisHashCmds(batches)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(commands) == 0 {
|
||||
return nil
|
||||
}
|
||||
return DoRedisBatch(ctx, executor, commands)
|
||||
}
|
||||
|
||||
// DoRedisBatch 执行一组 Redis 命令,优先使用批量执行器。
|
||||
func DoRedisBatch(ctx context.Context, executor RedisExecutor, commands []RedisCommand) error {
|
||||
if executor == nil {
|
||||
return errors.New("redis executor is nil")
|
||||
}
|
||||
if len(commands) == 0 {
|
||||
return nil
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if batcher, ok := executor.(RedisBatchExecutor); ok {
|
||||
return batcher.DoBatch(ctx, cloneRedisCommands(commands))
|
||||
}
|
||||
for _, cmd := range commands {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := executor.Do(ctx, cmd.Name, cmd.Args...); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func redisHashCmds(batches []RedisHashBatch) ([]RedisCommand, error) {
|
||||
if len(batches) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
merged, err := mergeRedisBatches(batches)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
commands := make([]RedisCommand, 0, len(merged)*2)
|
||||
for _, batch := range merged {
|
||||
key := strings.TrimSpace(batch.Key)
|
||||
values := batch.Fields
|
||||
names := make([]string, 0, len(values))
|
||||
for name := range values {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
hsetArgs := make([]any, 0, 1+len(names)*2)
|
||||
hsetArgs = append(hsetArgs, key)
|
||||
for _, name := range names {
|
||||
hsetArgs = append(hsetArgs, name, append([]byte(nil), values[name]...))
|
||||
}
|
||||
commands = append(commands, RedisCommand{Name: "HSET", Args: hsetArgs})
|
||||
ttlSeconds := int(batch.TTL.Seconds())
|
||||
if len(names) > 0 && ttlSeconds > 0 {
|
||||
commands = append(commands, RedisCommand{
|
||||
Name: "EXPIRE",
|
||||
Args: []any{key, ttlSeconds},
|
||||
})
|
||||
}
|
||||
}
|
||||
return commands, nil
|
||||
}
|
||||
|
||||
func mergeRedisBatches(batches []RedisHashBatch) ([]RedisHashBatch, error) {
|
||||
converted := make([]HashSaveBatch, 0, len(batches))
|
||||
for _, batch := range batches {
|
||||
converted = append(converted, HashSaveBatch(batch))
|
||||
}
|
||||
merged, err := mergeHashSaveBatches(converted)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]RedisHashBatch, 0, len(merged))
|
||||
for _, batch := range merged {
|
||||
out = append(out, RedisHashBatch(batch))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func cloneRedisCommands(commands []RedisCommand) []RedisCommand {
|
||||
if len(commands) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]RedisCommand, len(commands))
|
||||
for i, command := range commands {
|
||||
args := make([]any, len(command.Args))
|
||||
for j, arg := range command.Args {
|
||||
args[j] = cloneRedisCommandArg(arg)
|
||||
}
|
||||
out[i] = RedisCommand{
|
||||
Name: command.Name,
|
||||
Args: args,
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneRedisCommandArg(arg any) any {
|
||||
if data, ok := arg.([]byte); ok {
|
||||
return append([]byte(nil), data...)
|
||||
}
|
||||
return arg
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RedisHashBackend 用 Redis hash 实现 HashBackend。
|
||||
type RedisHashBackend struct {
|
||||
Executor RedisExecutor
|
||||
}
|
||||
|
||||
// NewRedisHashBackend 创建 Redis hash 后端。
|
||||
func NewRedisHashBackend(executor RedisExecutor) RedisHashBackend {
|
||||
return RedisHashBackend{Executor: executor}
|
||||
}
|
||||
|
||||
// LoadHash 从 Redis hash 读取字段。
|
||||
func (b RedisHashBackend) LoadHash(ctx context.Context, key string, fields []string) (map[string][]byte, bool, error) {
|
||||
if b.Executor == nil {
|
||||
return nil, false, errors.New("redis executor is nil")
|
||||
}
|
||||
ctx = contextOrBackground(ctx)
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
return nil, false, ErrRecordKeyRequired
|
||||
}
|
||||
fields = normHashFields(fields)
|
||||
if len(fields) == 0 {
|
||||
reply, err := b.Executor.Do(ctx, "HGETALL", key)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return redisHGetAllFields(reply)
|
||||
}
|
||||
args := make([]any, 0, 1+len(fields))
|
||||
args = append(args, key)
|
||||
for _, field := range fields {
|
||||
args = append(args, field)
|
||||
}
|
||||
reply, err := b.Executor.Do(ctx, "HMGET", args...)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
values, err := redisArray(reply)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if len(values) != len(fields) {
|
||||
return nil, false, fmt.Errorf("redis HMGET returned %d values for %d fields", len(values), len(fields))
|
||||
}
|
||||
out := make(map[string][]byte, len(fields))
|
||||
for i, value := range values {
|
||||
data, ok, err := redisBulkBytes(value)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if ok {
|
||||
out[fields[i]] = data
|
||||
}
|
||||
}
|
||||
return out, len(out) > 0, nil
|
||||
}
|
||||
|
||||
// SaveHash 保存 Redis hash 字段。
|
||||
func (b RedisHashBackend) SaveHash(ctx context.Context, key string, fields map[string][]byte, ttl time.Duration) error {
|
||||
return SaveRedisHashFields(ctx, b.Executor, key, fields, ttl)
|
||||
}
|
||||
|
||||
// SaveHashBatch 批量保存 Redis hash 字段。
|
||||
func (b RedisHashBackend) SaveHashBatch(ctx context.Context, batches []HashSaveBatch) error {
|
||||
merged, err := mergeHashSaveBatches(batches)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
redisBatches := make([]RedisHashBatch, 0, len(merged))
|
||||
for _, batch := range merged {
|
||||
redisBatches = append(redisBatches, RedisHashBatch(batch))
|
||||
}
|
||||
return SaveRedisHashFieldBatches(ctx, b.Executor, redisBatches)
|
||||
}
|
||||
|
||||
// Check 通过 PING 检查 Redis 连接。
|
||||
func (b RedisHashBackend) Check(ctx context.Context) error {
|
||||
if b.Executor == nil {
|
||||
return errors.New("redis executor is nil")
|
||||
}
|
||||
ctx = contextOrBackground(ctx)
|
||||
_, err := b.Executor.Do(ctx, "PING")
|
||||
return err
|
||||
}
|
||||
|
||||
func redisHGetAllFields(reply any) (map[string][]byte, bool, error) {
|
||||
values, err := redisArray(reply)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if len(values)%2 != 0 {
|
||||
return nil, false, fmt.Errorf("redis HGETALL returned odd value count %d", len(values))
|
||||
}
|
||||
out := make(map[string][]byte, len(values)/2)
|
||||
for i := 0; i < len(values); i += 2 {
|
||||
name, ok, err := redisBulkString(values[i])
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
data, ok, err := redisBulkBytes(values[i+1])
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if ok {
|
||||
out[name] = data
|
||||
}
|
||||
}
|
||||
return out, len(out) > 0, nil
|
||||
}
|
||||
|
||||
func redisArray(reply any) ([]any, error) {
|
||||
switch value := reply.(type) {
|
||||
case nil:
|
||||
return nil, nil
|
||||
case []any:
|
||||
return value, nil
|
||||
case [][]byte:
|
||||
out := make([]any, len(value))
|
||||
for i, item := range value {
|
||||
if item == nil {
|
||||
out[i] = nil
|
||||
} else {
|
||||
out[i] = append([]byte(nil), item...)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
case []string:
|
||||
out := make([]any, len(value))
|
||||
for i, item := range value {
|
||||
out[i] = item
|
||||
}
|
||||
return out, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unexpected redis array reply %T", reply)
|
||||
}
|
||||
}
|
||||
|
||||
func redisBulkString(value any) (string, bool, error) {
|
||||
data, ok, err := redisBulkBytes(value)
|
||||
if err != nil || !ok {
|
||||
return "", ok, err
|
||||
}
|
||||
return string(data), true, nil
|
||||
}
|
||||
|
||||
func redisBulkBytes(value any) ([]byte, bool, error) {
|
||||
switch v := value.(type) {
|
||||
case nil:
|
||||
return nil, false, nil
|
||||
case []byte:
|
||||
return append([]byte(nil), v...), true, nil
|
||||
case string:
|
||||
return []byte(v), true, nil
|
||||
default:
|
||||
return nil, false, fmt.Errorf("unexpected redis bulk reply %T", value)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrRecordKeyRequired 表示记录缺少可用主键。
|
||||
ErrRecordKeyRequired = errors.New("record key is required")
|
||||
// ErrAsyncStoreClosed 表示异步存储已关闭。
|
||||
ErrAsyncStoreClosed = errors.New("async store is closed")
|
||||
// ErrAllFieldsRequired 表示调用方需要提供完整字段集合。
|
||||
ErrAllFieldsRequired = errors.New("all fields function is required")
|
||||
)
|
||||
|
||||
// KeyFunc 从记录中提取稳定主键。
|
||||
type KeyFunc[T any] func(T) string
|
||||
|
||||
// CloneFunc 拷贝记录,避免缓存和调用方共享可变对象。
|
||||
type CloneFunc[T any] func(T) T
|
||||
|
||||
// Store 是最小记录存取接口。
|
||||
type Store[T any] interface {
|
||||
Load(context.Context, string) (T, bool, error)
|
||||
Save(context.Context, T) error
|
||||
}
|
||||
|
||||
// Checker 是存储预检接口。
|
||||
type Checker interface {
|
||||
Check(context.Context) error
|
||||
}
|
||||
|
||||
// FieldStore 支持按字段局部保存记录。
|
||||
type FieldStore[T any, F comparable] interface {
|
||||
Store[T]
|
||||
SaveFields(context.Context, T, ...F) error
|
||||
}
|
||||
|
||||
// FieldSave 表示一次局部字段保存请求。
|
||||
type FieldSave[T any, F comparable] struct {
|
||||
Record T
|
||||
Fields []F
|
||||
}
|
||||
|
||||
// BatchFieldStore 支持批量局部字段保存。
|
||||
type BatchFieldStore[T any, F comparable] interface {
|
||||
SaveFieldBatch(context.Context, []FieldSave[T, F]) error
|
||||
}
|
||||
|
||||
// SaveFieldsFunc 是可注入的局部保存函数。
|
||||
type SaveFieldsFunc[T any, F comparable] func(context.Context, Store[T], T, ...F) error
|
||||
|
||||
// SaveFieldBatchFunc 是可注入的批量局部保存函数。
|
||||
type SaveFieldBatchFunc[T any, F comparable] func(context.Context, Store[T], []FieldSave[T, F]) error
|
||||
|
||||
// Expirer 是支持设置记录过期时间的存储接口。
|
||||
type Expirer interface {
|
||||
Expire(context.Context, string, time.Duration) error
|
||||
}
|
||||
|
||||
// ExpireFunc 是可注入的过期设置函数。
|
||||
type ExpireFunc[T any] func(context.Context, Store[T], string, time.Duration) error
|
||||
|
||||
// IdentityClone 原样返回记录,适用于不可变值或调用方自行拷贝的场景。
|
||||
func IdentityClone[T any](record T) T {
|
||||
return record
|
||||
}
|
||||
|
||||
// RecordKey 提取并校验记录主键。
|
||||
func RecordKey[T any](keyFn KeyFunc[T], record T) (string, error) {
|
||||
if keyFn == nil {
|
||||
return "", fmt.Errorf("%w: key function is nil", ErrRecordKeyRequired)
|
||||
}
|
||||
key := strings.TrimSpace(keyFn(record))
|
||||
if key == "" {
|
||||
return "", ErrRecordKeyRequired
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// SaveFields 按字段保存;底层不支持字段保存时退化为整条保存。
|
||||
func SaveFields[T any, F comparable](ctx context.Context, store Store[T], record T, normalize func([]F) []F, fields ...F) error {
|
||||
if store == nil {
|
||||
return errors.New("store is nil")
|
||||
}
|
||||
ctx = contextOrBackground(ctx)
|
||||
if normalize != nil {
|
||||
fields = normalize(fields)
|
||||
}
|
||||
if len(fields) == 0 {
|
||||
return nil
|
||||
}
|
||||
if fieldStore, ok := store.(FieldStore[T, F]); ok {
|
||||
return fieldStore.SaveFields(ctx, record, fields...)
|
||||
}
|
||||
return store.Save(ctx, record)
|
||||
}
|
||||
|
||||
// SaveFieldBatch 批量保存字段;底层不支持批量时逐条退化。
|
||||
func SaveFieldBatch[T any, F comparable](ctx context.Context, store Store[T], normalize func([]F) []F, saves []FieldSave[T, F]) error {
|
||||
if store == nil {
|
||||
return errors.New("store is nil")
|
||||
}
|
||||
ctx = contextOrBackground(ctx)
|
||||
batch := normalizeFieldSaves(normalize, saves)
|
||||
if len(batch) == 0 {
|
||||
return nil
|
||||
}
|
||||
if batchStore, ok := store.(BatchFieldStore[T, F]); ok {
|
||||
return batchStore.SaveFieldBatch(ctx, batch)
|
||||
}
|
||||
for _, save := range batch {
|
||||
if err := SaveFields(ctx, store, save.Record, normalize, save.Fields...); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Expire 在存储支持过期能力时设置记录 TTL。
|
||||
func Expire(ctx context.Context, store any, key string, ttl time.Duration) error {
|
||||
if ttl <= 0 {
|
||||
return nil
|
||||
}
|
||||
ctx = contextOrBackground(ctx)
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
expirer, ok := store.(Expirer)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return expirer.Expire(ctx, key, ttl)
|
||||
}
|
||||
|
||||
func normalizeFieldSaves[T any, F comparable](normalize func([]F) []F, saves []FieldSave[T, F]) []FieldSave[T, F] {
|
||||
if len(saves) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]FieldSave[T, F], 0, len(saves))
|
||||
for _, save := range saves {
|
||||
fields := append([]F(nil), save.Fields...)
|
||||
if normalize != nil {
|
||||
fields = normalize(fields)
|
||||
}
|
||||
if len(fields) == 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out, FieldSave[T, F]{
|
||||
Record: save.Record,
|
||||
Fields: fields,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// CloseOrFlush 优先关闭存储,否则刷新待写队列。
|
||||
func CloseOrFlush(ctx context.Context, store any) error {
|
||||
if closer, ok := store.(interface {
|
||||
Close(context.Context) error
|
||||
}); ok {
|
||||
return closer.Close(ctx)
|
||||
}
|
||||
if flusher, ok := store.(interface {
|
||||
Flush(context.Context) error
|
||||
}); ok {
|
||||
return flusher.Flush(ctx)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check 对实现 Checker 的对象执行预检。
|
||||
func Check(ctx context.Context, target any) error {
|
||||
ctx = contextOrBackground(ctx)
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if checker, ok := target.(Checker); ok {
|
||||
return checker.Check(ctx)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func contextOrBackground(ctx context.Context) context.Context {
|
||||
if ctx == nil {
|
||||
return context.Background()
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
Reference in new issue
Block a user