Initial open source release
This commit is contained in:
commit
74992e893f
377 changed files with 118084 additions and 0 deletions
303
internal/store/redisstore/allocator.go
Normal file
303
internal/store/redisstore/allocator.go
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
package redisstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
// PtsAllocator 用 Redis INCR 分配账号级 pts,Redis 丢失时从 PG durable log 恢复当前最大 pts。
|
||||
type PtsAllocator struct {
|
||||
counter counterAllocator
|
||||
}
|
||||
|
||||
// BoxIDAllocator 用 Redis INCR 分配 owner 视角的 message box id。
|
||||
type BoxIDAllocator struct {
|
||||
counter counterAllocator
|
||||
}
|
||||
|
||||
// ChannelPtsAllocator 用 Redis INCR 分配 channel 维度 pts。
|
||||
type ChannelPtsAllocator struct {
|
||||
counter counterAllocator
|
||||
}
|
||||
|
||||
// ChannelIDAllocator 用 Redis INCR 分配全局 channel/supergroup id。
|
||||
type ChannelIDAllocator struct {
|
||||
counter counterAllocator
|
||||
}
|
||||
|
||||
// ChannelMessageIDAllocator 用 Redis INCR 分配 channel 维度 message id。
|
||||
type ChannelMessageIDAllocator struct {
|
||||
counter counterAllocator
|
||||
}
|
||||
|
||||
type counterAllocator struct {
|
||||
c *redis.Client
|
||||
source store.CounterSource
|
||||
key func(int64) string
|
||||
name string
|
||||
}
|
||||
|
||||
const missingCounterSentinel int64 = -1
|
||||
|
||||
var (
|
||||
counterNextScript = redis.NewScript(`
|
||||
local current = redis.call("GET", KEYS[1])
|
||||
if current then
|
||||
return redis.call("INCR", KEYS[1])
|
||||
end
|
||||
return -1
|
||||
`)
|
||||
|
||||
counterNextByScript = redis.NewScript(`
|
||||
local current = redis.call("GET", KEYS[1])
|
||||
if current then
|
||||
return redis.call("INCRBY", KEYS[1], ARGV[1])
|
||||
end
|
||||
return -1
|
||||
`)
|
||||
|
||||
counterRecoverCurrentScript = redis.NewScript(`
|
||||
local current = redis.call("GET", KEYS[1])
|
||||
if current then
|
||||
return tonumber(current)
|
||||
end
|
||||
redis.call("SET", KEYS[1], ARGV[1])
|
||||
return tonumber(ARGV[1])
|
||||
`)
|
||||
|
||||
counterRecoverNextScript = redis.NewScript(`
|
||||
local current = redis.call("GET", KEYS[1])
|
||||
if not current then
|
||||
redis.call("SET", KEYS[1], ARGV[1])
|
||||
end
|
||||
return redis.call("INCR", KEYS[1])
|
||||
`)
|
||||
|
||||
counterRecoverNextByScript = redis.NewScript(`
|
||||
local current = redis.call("GET", KEYS[1])
|
||||
if not current then
|
||||
redis.call("SET", KEYS[1], ARGV[1])
|
||||
end
|
||||
return redis.call("INCRBY", KEYS[1], ARGV[2])
|
||||
`)
|
||||
)
|
||||
|
||||
// NewPtsAllocator 创建 Redis-backed pts allocator。
|
||||
func NewPtsAllocator(c *redis.Client, source store.CounterSource) *PtsAllocator {
|
||||
return &PtsAllocator{counter: counterAllocator{
|
||||
c: c,
|
||||
source: source,
|
||||
key: ptsKey,
|
||||
name: "pts",
|
||||
}}
|
||||
}
|
||||
|
||||
// NewBoxIDAllocator 创建 Redis-backed message box id allocator。
|
||||
func NewBoxIDAllocator(c *redis.Client, source store.CounterSource) *BoxIDAllocator {
|
||||
return &BoxIDAllocator{counter: counterAllocator{
|
||||
c: c,
|
||||
source: source,
|
||||
key: boxIDKey,
|
||||
name: "box_id",
|
||||
}}
|
||||
}
|
||||
|
||||
// NewChannelPtsAllocator 创建 Redis-backed channel pts allocator。
|
||||
func NewChannelPtsAllocator(c *redis.Client, source store.CounterSource) *ChannelPtsAllocator {
|
||||
return &ChannelPtsAllocator{counter: counterAllocator{
|
||||
c: c,
|
||||
source: source,
|
||||
key: channelPtsKey,
|
||||
name: "channel_pts",
|
||||
}}
|
||||
}
|
||||
|
||||
// NewChannelIDAllocator 创建 Redis-backed channel id allocator。
|
||||
func NewChannelIDAllocator(c *redis.Client, source store.CounterSource) *ChannelIDAllocator {
|
||||
return &ChannelIDAllocator{counter: counterAllocator{
|
||||
c: c,
|
||||
source: source,
|
||||
key: channelIDKey,
|
||||
name: "channel_id",
|
||||
}}
|
||||
}
|
||||
|
||||
// NewChannelMessageIDAllocator 创建 Redis-backed channel message id allocator。
|
||||
func NewChannelMessageIDAllocator(c *redis.Client, source store.CounterSource) *ChannelMessageIDAllocator {
|
||||
return &ChannelMessageIDAllocator{counter: counterAllocator{
|
||||
c: c,
|
||||
source: source,
|
||||
key: channelMessageIDKey,
|
||||
name: "channel_msg_id",
|
||||
}}
|
||||
}
|
||||
|
||||
func ptsKey(userID int64) string {
|
||||
return fmt.Sprintf("counter:pts:{%d}", userID)
|
||||
}
|
||||
|
||||
func boxIDKey(userID int64) string {
|
||||
return fmt.Sprintf("counter:box_id:{%d}", userID)
|
||||
}
|
||||
|
||||
func channelPtsKey(channelID int64) string {
|
||||
return fmt.Sprintf("counter:channel_pts:{%d}", channelID)
|
||||
}
|
||||
|
||||
func channelIDKey(_ int64) string {
|
||||
return "counter:channel_id"
|
||||
}
|
||||
|
||||
func channelMessageIDKey(channelID int64) string {
|
||||
return fmt.Sprintf("counter:channel_msg_id:{%d}", channelID)
|
||||
}
|
||||
|
||||
func (a *PtsAllocator) NextPts(ctx context.Context, userID int64) (int, error) {
|
||||
v, err := a.counter.next(ctx, userID)
|
||||
return int(v), err
|
||||
}
|
||||
|
||||
func (a *PtsAllocator) NextPtsN(ctx context.Context, userID int64, count int) (int, error) {
|
||||
v, err := a.counter.nextBy(ctx, userID, count)
|
||||
return int(v), err
|
||||
}
|
||||
|
||||
func (a *PtsAllocator) CurrentPts(ctx context.Context, userID int64) (int, error) {
|
||||
v, err := a.counter.current(ctx, userID)
|
||||
return int(v), err
|
||||
}
|
||||
|
||||
func (a *BoxIDAllocator) NextBoxID(ctx context.Context, userID int64) (int, error) {
|
||||
v, err := a.counter.next(ctx, userID)
|
||||
return int(v), err
|
||||
}
|
||||
|
||||
func (a *BoxIDAllocator) CurrentBoxID(ctx context.Context, userID int64) (int, error) {
|
||||
v, err := a.counter.current(ctx, userID)
|
||||
return int(v), err
|
||||
}
|
||||
|
||||
func (a *ChannelPtsAllocator) NextChannelPts(ctx context.Context, channelID int64) (int, error) {
|
||||
v, err := a.counter.next(ctx, channelID)
|
||||
return int(v), err
|
||||
}
|
||||
|
||||
func (a *ChannelPtsAllocator) NextChannelPtsN(ctx context.Context, channelID int64, count int) (int, error) {
|
||||
v, err := a.counter.nextBy(ctx, channelID, count)
|
||||
return int(v), err
|
||||
}
|
||||
|
||||
func (a *ChannelPtsAllocator) CurrentChannelPts(ctx context.Context, channelID int64) (int, error) {
|
||||
v, err := a.counter.current(ctx, channelID)
|
||||
return int(v), err
|
||||
}
|
||||
|
||||
func (a *ChannelIDAllocator) NextChannelID(ctx context.Context) (int64, error) {
|
||||
return a.counter.next(ctx, 1)
|
||||
}
|
||||
|
||||
func (a *ChannelIDAllocator) CurrentChannelID(ctx context.Context) (int64, error) {
|
||||
return a.counter.current(ctx, 1)
|
||||
}
|
||||
|
||||
func (a *ChannelMessageIDAllocator) NextChannelMessageID(ctx context.Context, channelID int64) (int, error) {
|
||||
v, err := a.counter.next(ctx, channelID)
|
||||
return int(v), err
|
||||
}
|
||||
|
||||
func (a *ChannelMessageIDAllocator) CurrentChannelMessageID(ctx context.Context, channelID int64) (int, error) {
|
||||
v, err := a.counter.current(ctx, channelID)
|
||||
return int(v), err
|
||||
}
|
||||
|
||||
func (a counterAllocator) next(ctx context.Context, userID int64) (int64, error) {
|
||||
return a.nextBy(ctx, userID, 1)
|
||||
}
|
||||
|
||||
func (a counterAllocator) nextBy(ctx context.Context, userID int64, count int) (int64, error) {
|
||||
if count <= 0 {
|
||||
return 0, fmt.Errorf("redis next %s counter: invalid count %d", a.name, count)
|
||||
}
|
||||
key, err := a.validatedKey(userID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
script := counterNextScript
|
||||
args := []any{}
|
||||
if count > 1 {
|
||||
script = counterNextByScript
|
||||
args = append(args, count)
|
||||
}
|
||||
v, err := script.Run(ctx, a.c, []string{key}, args...).Int64()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("redis next %s counter: %w", a.name, err)
|
||||
}
|
||||
if v != missingCounterSentinel {
|
||||
return v, nil
|
||||
}
|
||||
recovered, err := a.recovered(ctx, userID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
recoverScript := counterRecoverNextScript
|
||||
recoverArgs := []any{recovered}
|
||||
if count > 1 {
|
||||
recoverScript = counterRecoverNextByScript
|
||||
recoverArgs = append(recoverArgs, count)
|
||||
}
|
||||
v, err = recoverScript.Run(ctx, a.c, []string{key}, recoverArgs...).Int64()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("redis recover-next %s counter: %w", a.name, err)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func (a counterAllocator) current(ctx context.Context, userID int64) (int64, error) {
|
||||
key, err := a.validatedKey(userID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
v, err := a.c.Get(ctx, key).Int64()
|
||||
if err == nil {
|
||||
return v, nil
|
||||
}
|
||||
if !errors.Is(err, redis.Nil) {
|
||||
return 0, fmt.Errorf("redis get %s counter: %w", a.name, err)
|
||||
}
|
||||
recovered, err := a.recovered(ctx, userID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
v, err = counterRecoverCurrentScript.Run(ctx, a.c, []string{key}, recovered).Int64()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("redis recover-current %s counter: %w", a.name, err)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func (a counterAllocator) validatedKey(userID int64) (string, error) {
|
||||
if userID == 0 {
|
||||
return "", fmt.Errorf("redis %s counter: missing user id", a.name)
|
||||
}
|
||||
if a.c == nil {
|
||||
return "", fmt.Errorf("redis %s counter: nil client", a.name)
|
||||
}
|
||||
return a.key(userID), nil
|
||||
}
|
||||
|
||||
func (a counterAllocator) recovered(ctx context.Context, userID int64) (int, error) {
|
||||
recovered := 0
|
||||
var err error
|
||||
if a.source != nil {
|
||||
recovered, err = a.source.Current(ctx, userID)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("recover %s counter: %w", a.name, err)
|
||||
}
|
||||
}
|
||||
return recovered, nil
|
||||
}
|
||||
160
internal/store/redisstore/allocator_integration_test.go
Normal file
160
internal/store/redisstore/allocator_integration_test.go
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
package redisstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type staticCounterSource struct {
|
||||
value int
|
||||
}
|
||||
|
||||
func (s staticCounterSource) Current(context.Context, int64) (int, error) {
|
||||
return s.value, nil
|
||||
}
|
||||
|
||||
func TestRedisAllocatorsRecoverFromCounterSource(t *testing.T) {
|
||||
addr := os.Getenv("TELESRV_TEST_REDIS_ADDR")
|
||||
if addr == "" {
|
||||
t.Skip("set TELESRV_TEST_REDIS_ADDR to run redis integration test")
|
||||
}
|
||||
ctx := context.Background()
|
||||
c, err := Open(ctx, addr, "", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = c.Close() })
|
||||
|
||||
userID := time.Now().UnixNano()
|
||||
t.Cleanup(func() { _ = c.Del(ctx, ptsKey(userID), boxIDKey(userID)).Err() })
|
||||
|
||||
pts := NewPtsAllocator(c, staticCounterSource{value: 41})
|
||||
currentPts, err := pts.CurrentPts(ctx, userID)
|
||||
if err != nil {
|
||||
t.Fatalf("CurrentPts: %v", err)
|
||||
}
|
||||
if currentPts != 41 {
|
||||
t.Fatalf("current pts = %d, want recovered 41", currentPts)
|
||||
}
|
||||
nextPts, err := pts.NextPts(ctx, userID)
|
||||
if err != nil {
|
||||
t.Fatalf("NextPts: %v", err)
|
||||
}
|
||||
if nextPts != 42 {
|
||||
t.Fatalf("next pts = %d, want 42", nextPts)
|
||||
}
|
||||
|
||||
boxes := NewBoxIDAllocator(c, staticCounterSource{value: 100})
|
||||
currentBox, err := boxes.CurrentBoxID(ctx, userID)
|
||||
if err != nil {
|
||||
t.Fatalf("CurrentBoxID: %v", err)
|
||||
}
|
||||
if currentBox != 100 {
|
||||
t.Fatalf("current box = %d, want recovered 100", currentBox)
|
||||
}
|
||||
nextBox, err := boxes.NextBoxID(ctx, userID)
|
||||
if err != nil {
|
||||
t.Fatalf("NextBoxID: %v", err)
|
||||
}
|
||||
if nextBox != 101 {
|
||||
t.Fatalf("next box = %d, want 101", nextBox)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedisAllocatorConcurrentFirstUse(t *testing.T) {
|
||||
addr := os.Getenv("TELESRV_TEST_REDIS_ADDR")
|
||||
if addr == "" {
|
||||
t.Skip("set TELESRV_TEST_REDIS_ADDR to run redis integration test")
|
||||
}
|
||||
ctx := context.Background()
|
||||
c, err := Open(ctx, addr, "", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = c.Close() })
|
||||
|
||||
userID := time.Now().UnixNano()
|
||||
t.Cleanup(func() { _ = c.Del(ctx, ptsKey(userID)).Err() })
|
||||
|
||||
const workers = 32
|
||||
pts := NewPtsAllocator(c, staticCounterSource{value: 1000})
|
||||
values := make(chan int, workers)
|
||||
errs := make(chan error, workers)
|
||||
var wg sync.WaitGroup
|
||||
for range workers {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
v, err := pts.NextPts(ctx, userID)
|
||||
if err != nil {
|
||||
errs <- err
|
||||
return
|
||||
}
|
||||
values <- v
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
close(values)
|
||||
close(errs)
|
||||
|
||||
for err := range errs {
|
||||
t.Fatalf("NextPts: %v", err)
|
||||
}
|
||||
seen := make(map[int]bool, workers)
|
||||
for v := range values {
|
||||
if v < 1001 || v > 1000+workers {
|
||||
t.Fatalf("pts = %d, want recovered contiguous range", v)
|
||||
}
|
||||
if seen[v] {
|
||||
t.Fatalf("duplicate pts %d", v)
|
||||
}
|
||||
seen[v] = true
|
||||
}
|
||||
for want := 1001; want <= 1000+workers; want++ {
|
||||
if !seen[want] {
|
||||
t.Fatalf("missing pts %d", want)
|
||||
}
|
||||
}
|
||||
current, err := pts.CurrentPts(ctx, userID)
|
||||
if err != nil {
|
||||
t.Fatalf("CurrentPts: %v", err)
|
||||
}
|
||||
if current != 1000+workers {
|
||||
t.Fatalf("current pts = %d, want %d", current, 1000+workers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedisRateLimiterWindow(t *testing.T) {
|
||||
addr := os.Getenv("TELESRV_TEST_REDIS_ADDR")
|
||||
if addr == "" {
|
||||
t.Skip("set TELESRV_TEST_REDIS_ADDR to run redis integration test")
|
||||
}
|
||||
ctx := context.Background()
|
||||
c, err := Open(ctx, addr, "", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = c.Close() })
|
||||
|
||||
key := "test:" + time.Now().Format("150405.000000000")
|
||||
t.Cleanup(func() { _ = c.Del(ctx, rateLimitKey(key)).Err() })
|
||||
|
||||
limiter := NewRateLimiter(c)
|
||||
allowed, retry, err := limiter.Allow(ctx, key, 1, time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("Allow first: %v", err)
|
||||
}
|
||||
if !allowed || retry != 0 {
|
||||
t.Fatalf("first allowed=%v retry=%d, want allowed", allowed, retry)
|
||||
}
|
||||
allowed, retry, err = limiter.Allow(ctx, key, 1, time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("Allow second: %v", err)
|
||||
}
|
||||
if allowed || retry <= 0 {
|
||||
t.Fatalf("second allowed=%v retry=%d, want limited with retry", allowed, retry)
|
||||
}
|
||||
}
|
||||
55
internal/store/redisstore/code.go
Normal file
55
internal/store/redisstore/code.go
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
package redisstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
// CodeStore 用 Redis 实现 store.CodeStore(验证码带 TTL 自动过期)。
|
||||
type CodeStore struct {
|
||||
c *redis.Client
|
||||
}
|
||||
|
||||
// NewCodeStore 创建 Redis CodeStore。
|
||||
func NewCodeStore(c *redis.Client) *CodeStore {
|
||||
return &CodeStore{c: c}
|
||||
}
|
||||
|
||||
func codeKey(hash string) string { return "phonecode:" + hash }
|
||||
|
||||
func (s *CodeStore) Set(ctx context.Context, hash string, code store.PhoneCode, ttl time.Duration) error {
|
||||
v, err := json.Marshal(code)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal phone code: %w", err)
|
||||
}
|
||||
if err := s.c.Set(ctx, codeKey(hash), v, ttl).Err(); err != nil {
|
||||
return fmt.Errorf("redis set phone code: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *CodeStore) Get(ctx context.Context, hash string) (store.PhoneCode, bool, error) {
|
||||
raw, err := s.c.Get(ctx, codeKey(hash)).Bytes()
|
||||
if err != nil {
|
||||
if errors.Is(err, redis.Nil) {
|
||||
return store.PhoneCode{}, false, nil
|
||||
}
|
||||
return store.PhoneCode{}, false, fmt.Errorf("redis get phone code: %w", err)
|
||||
}
|
||||
var code store.PhoneCode
|
||||
if err := json.Unmarshal(raw, &code); err != nil {
|
||||
return store.PhoneCode{}, false, fmt.Errorf("unmarshal phone code: %w", err)
|
||||
}
|
||||
return code, true, nil
|
||||
}
|
||||
|
||||
func (s *CodeStore) Del(ctx context.Context, hash string) error {
|
||||
return s.c.Del(ctx, codeKey(hash)).Err()
|
||||
}
|
||||
57
internal/store/redisstore/ratelimit.go
Normal file
57
internal/store/redisstore/ratelimit.go
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
package redisstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// RateLimiter 用 Redis INCR + TTL 实现固定窗口限流。
|
||||
type RateLimiter struct {
|
||||
c *redis.Client
|
||||
}
|
||||
|
||||
// NewRateLimiter 创建 Redis-backed RateLimiter。
|
||||
func NewRateLimiter(c *redis.Client) *RateLimiter {
|
||||
return &RateLimiter{c: c}
|
||||
}
|
||||
|
||||
func rateLimitKey(key string) string {
|
||||
return "ratelimit:" + key
|
||||
}
|
||||
|
||||
func (l *RateLimiter) Allow(ctx context.Context, key string, limit int, window time.Duration) (bool, int, error) {
|
||||
if limit <= 0 {
|
||||
return true, 0, nil
|
||||
}
|
||||
if window <= 0 {
|
||||
window = time.Second
|
||||
}
|
||||
if l == nil || l.c == nil {
|
||||
return false, 0, fmt.Errorf("redis rate limiter: nil client")
|
||||
}
|
||||
redisKey := rateLimitKey(key)
|
||||
count, err := l.c.Incr(ctx, redisKey).Result()
|
||||
if err != nil {
|
||||
return false, 0, fmt.Errorf("redis incr rate limit: %w", err)
|
||||
}
|
||||
if count == 1 {
|
||||
if err := l.c.Expire(ctx, redisKey, window).Err(); err != nil {
|
||||
return false, 0, fmt.Errorf("redis expire rate limit: %w", err)
|
||||
}
|
||||
}
|
||||
if count <= int64(limit) {
|
||||
return true, 0, nil
|
||||
}
|
||||
ttl, err := l.c.TTL(ctx, redisKey).Result()
|
||||
if err != nil {
|
||||
return false, 0, fmt.Errorf("redis ttl rate limit: %w", err)
|
||||
}
|
||||
if ttl <= 0 {
|
||||
ttl = window
|
||||
}
|
||||
return false, int(math.Ceil(ttl.Seconds())), nil
|
||||
}
|
||||
25
internal/store/redisstore/redis.go
Normal file
25
internal/store/redisstore/redis.go
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
// Package redisstore 用 Redis 实现高频易失态的存储接口(第一阶段:SessionStore)。
|
||||
//
|
||||
// 职责边界见 docs/persistence-layer.md §1:Redis 存「态与计数」,丢失可由 PG/协议恢复。
|
||||
package redisstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// Open 按地址建立 Redis 连接并 ping 验证。
|
||||
func Open(ctx context.Context, addr, password string, db int) (*redis.Client, error) {
|
||||
c := redis.NewClient(&redis.Options{
|
||||
Addr: addr,
|
||||
Password: password,
|
||||
DB: db,
|
||||
})
|
||||
if err := c.Ping(ctx).Err(); err != nil {
|
||||
_ = c.Close()
|
||||
return nil, fmt.Errorf("redis ping %s: %w", addr, err)
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
75
internal/store/redisstore/session.go
Normal file
75
internal/store/redisstore/session.go
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
package redisstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
// DefaultSessionTTL 是 session 记录的默认过期时间。
|
||||
// session 是连接态:过期或丢失后,客户端重连会触发 new_session_created / bad_server_salt 重建,
|
||||
// 因此 TTL 不必很长。
|
||||
const DefaultSessionTTL = 30 * 24 * time.Hour
|
||||
|
||||
// SessionStore 用 Redis 实现 store.SessionStore。
|
||||
type SessionStore struct {
|
||||
c *redis.Client
|
||||
ttl time.Duration
|
||||
}
|
||||
|
||||
// NewSessionStore 创建 Redis SessionStore。ttl<=0 表示永不过期。
|
||||
func NewSessionStore(c *redis.Client, ttl time.Duration) *SessionStore {
|
||||
return &SessionStore{c: c, ttl: ttl}
|
||||
}
|
||||
|
||||
func sessionKey(id int64) string {
|
||||
return fmt.Sprintf("session:%d", id)
|
||||
}
|
||||
|
||||
// sessionValue 是 SessionData 在 Redis 中的序列化形态(不含 ID,ID 即 key)。
|
||||
type sessionValue struct {
|
||||
AuthKeyID [8]byte `json:"auth_key_id"`
|
||||
Salt int64 `json:"salt"`
|
||||
LastSeen int64 `json:"last_seen"`
|
||||
}
|
||||
|
||||
// Save 实现 store.SessionStore。
|
||||
func (s *SessionStore) Save(ctx context.Context, d store.SessionData) error {
|
||||
v, err := json.Marshal(sessionValue{AuthKeyID: d.AuthKeyID, Salt: d.Salt, LastSeen: d.LastSeen})
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal session: %w", err)
|
||||
}
|
||||
if err := s.c.Set(ctx, sessionKey(d.ID), v, s.ttl).Err(); err != nil {
|
||||
return fmt.Errorf("redis set session: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get 实现 store.SessionStore。不存在时 found=false。
|
||||
func (s *SessionStore) Get(ctx context.Context, id int64) (store.SessionData, bool, error) {
|
||||
raw, err := s.c.Get(ctx, sessionKey(id)).Bytes()
|
||||
if err != nil {
|
||||
if errors.Is(err, redis.Nil) {
|
||||
return store.SessionData{}, false, nil
|
||||
}
|
||||
return store.SessionData{}, false, fmt.Errorf("redis get session: %w", err)
|
||||
}
|
||||
var v sessionValue
|
||||
if err := json.Unmarshal(raw, &v); err != nil {
|
||||
return store.SessionData{}, false, fmt.Errorf("unmarshal session: %w", err)
|
||||
}
|
||||
return store.SessionData{ID: id, AuthKeyID: v.AuthKeyID, Salt: v.Salt, LastSeen: v.LastSeen}, true, nil
|
||||
}
|
||||
|
||||
func (s *SessionStore) Delete(ctx context.Context, id int64) error {
|
||||
if err := s.c.Del(ctx, sessionKey(id)).Err(); err != nil {
|
||||
return fmt.Errorf("redis delete session: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
52
internal/store/redisstore/session_integration_test.go
Normal file
52
internal/store/redisstore/session_integration_test.go
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
package redisstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
// TestSessionStoreRoundTrip 验证 session 落 Redis 后能用全新 store 实例原样读回。
|
||||
// 未设 TELESRV_TEST_REDIS_ADDR 则跳过。
|
||||
func TestSessionStoreRoundTrip(t *testing.T) {
|
||||
addr := os.Getenv("TELESRV_TEST_REDIS_ADDR")
|
||||
if addr == "" {
|
||||
t.Skip("set TELESRV_TEST_REDIS_ADDR to run redis integration test")
|
||||
}
|
||||
ctx := context.Background()
|
||||
c, err := Open(ctx, addr, "", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = c.Close() })
|
||||
|
||||
want := store.SessionData{
|
||||
ID: 0x1234beef,
|
||||
AuthKeyID: [8]byte{1, 2, 3, 4, 5, 6, 7, 8},
|
||||
Salt: 42,
|
||||
LastSeen: 1000,
|
||||
}
|
||||
t.Cleanup(func() { _ = c.Del(ctx, sessionKey(want.ID)).Err() })
|
||||
|
||||
if err := NewSessionStore(c, time.Minute).Save(ctx, want); err != nil {
|
||||
t.Fatalf("save: %v", err)
|
||||
}
|
||||
|
||||
got, found, err := NewSessionStore(c, time.Minute).Get(ctx, want.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("session not found after save")
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("round trip mismatch: got %+v want %+v", got, want)
|
||||
}
|
||||
|
||||
if _, found, _ := NewSessionStore(c, time.Minute).Get(ctx, 999999); found {
|
||||
t.Fatal("unexpected found for missing session")
|
||||
}
|
||||
}
|
||||
112
internal/store/redisstore/user_counter_allocator.go
Normal file
112
internal/store/redisstore/user_counter_allocator.go
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
package redisstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
// UserCounterAllocator 在一次 Lua 往返内同时分配某 user 的下一个 pts 与 box_id。
|
||||
//
|
||||
// counter:pts:{userID} 与 counter:box_id:{userID} 共享 hash-tag {userID},处于同一
|
||||
// Redis Cluster slot,可被单个 Lua 脚本原子操作——把发送热路径上「pts + box 两次往返」
|
||||
// 合并成一次。语义与 PtsAllocator/BoxIDAllocator 各自调用等价:pts 账号级无洞、
|
||||
// Redis 冷 miss 时从 PG durable log 恢复基线。
|
||||
type UserCounterAllocator struct {
|
||||
c *redis.Client
|
||||
ptsSource store.CounterSource
|
||||
boxSource store.CounterSource
|
||||
}
|
||||
|
||||
// NewUserCounterAllocator 创建合并 allocator。ptsSource 恢复 pts(MAX(user_update_events.pts)),
|
||||
// boxSource 恢复 box_id(MAX(message_boxes.box_id))。
|
||||
func NewUserCounterAllocator(c *redis.Client, ptsSource, boxSource store.CounterSource) *UserCounterAllocator {
|
||||
return &UserCounterAllocator{c: c, ptsSource: ptsSource, boxSource: boxSource}
|
||||
}
|
||||
|
||||
// userCountersNextScript 热路径:两 key 都存在时各自 INCR 并返回;任一缺失返回 {-1,-1} 触发恢复。
|
||||
var userCountersNextScript = redis.NewScript(`
|
||||
local pts = redis.call("GET", KEYS[1])
|
||||
local box = redis.call("GET", KEYS[2])
|
||||
if pts and box then
|
||||
return {redis.call("INCR", KEYS[1]), redis.call("INCR", KEYS[2])}
|
||||
end
|
||||
return {-1, -1}
|
||||
`)
|
||||
|
||||
// userCountersRecoverScript 冷路径:每个 key「缺失才用 PG 基线 SET,再 INCR」。
|
||||
// 对已存在的 key 跳过 SET 只 INCR,故对「一存一缺」也安全;Redis 单线程串行化保证无重复无洞。
|
||||
var userCountersRecoverScript = redis.NewScript(`
|
||||
local function recnext(key, base)
|
||||
if not redis.call("GET", key) then
|
||||
redis.call("SET", key, base)
|
||||
end
|
||||
return redis.call("INCR", key)
|
||||
end
|
||||
return {recnext(KEYS[1], ARGV[1]), recnext(KEYS[2], ARGV[2])}
|
||||
`)
|
||||
|
||||
// NextUserCounters 返回该 user 的下一个 (pts, boxID)。
|
||||
func (a *UserCounterAllocator) NextUserCounters(ctx context.Context, userID int64) (int, int, error) {
|
||||
if userID == 0 {
|
||||
return 0, 0, fmt.Errorf("redis user counters: missing user id")
|
||||
}
|
||||
if a.c == nil {
|
||||
return 0, 0, fmt.Errorf("redis user counters: nil client")
|
||||
}
|
||||
keys := []string{ptsKey(userID), boxIDKey(userID)}
|
||||
|
||||
pts, box, err := runTwoCounters(ctx, a.c, userCountersNextScript, keys)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("redis next user counters: %w", err)
|
||||
}
|
||||
if pts != missingCounterSentinel && box != missingCounterSentinel {
|
||||
return int(pts), int(box), nil
|
||||
}
|
||||
|
||||
// 冷路径:至少一个 key 缺失,从 PG durable log 恢复两个基线,再 recover-next。
|
||||
ptsBase, err := recoverBase(ctx, a.ptsSource, userID, "pts")
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
boxBase, err := recoverBase(ctx, a.boxSource, userID, "box_id")
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
pts, box, err = runTwoCounters(ctx, a.c, userCountersRecoverScript, keys, ptsBase, boxBase)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("redis recover user counters: %w", err)
|
||||
}
|
||||
return int(pts), int(box), nil
|
||||
}
|
||||
|
||||
// runTwoCounters 执行返回二元整数表的 Lua 脚本,用 .Slice() 手动解析(不依赖 Int64Slice)。
|
||||
func runTwoCounters(ctx context.Context, c *redis.Client, script *redis.Script, keys []string, args ...any) (int64, int64, error) {
|
||||
raw, err := script.Run(ctx, c, keys, args...).Slice()
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
if len(raw) != 2 {
|
||||
return 0, 0, fmt.Errorf("unexpected reply len %d, want 2", len(raw))
|
||||
}
|
||||
a, okA := raw[0].(int64)
|
||||
b, okB := raw[1].(int64)
|
||||
if !okA || !okB {
|
||||
return 0, 0, fmt.Errorf("unexpected reply element types %T,%T, want int64", raw[0], raw[1])
|
||||
}
|
||||
return a, b, nil
|
||||
}
|
||||
|
||||
func recoverBase(ctx context.Context, source store.CounterSource, userID int64, name string) (int, error) {
|
||||
if source == nil {
|
||||
return 0, nil
|
||||
}
|
||||
v, err := source.Current(ctx, userID)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("recover %s counter: %w", name, err)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue