merged from gramsrv upstream
This commit is contained in:
parent
79c64ee916
commit
21a0856587
651 changed files with 54774 additions and 4590 deletions
135
internal/store/redisstore/active_channel_ids_page.go
Normal file
135
internal/store/redisstore/active_channel_ids_page.go
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
package redisstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultActiveChannelIDsPageTTL = 24 * time.Hour
|
||||
activeChannelIDsPageSchemaV1 = 1
|
||||
activeChannelIDsPageMaxBytes = 64 << 10
|
||||
)
|
||||
|
||||
type ActiveChannelIDsPageCache struct {
|
||||
c *redis.Client
|
||||
ttl time.Duration
|
||||
}
|
||||
|
||||
func NewActiveChannelIDsPageCache(c *redis.Client, ttl time.Duration) *ActiveChannelIDsPageCache {
|
||||
if ttl <= 0 {
|
||||
ttl = DefaultActiveChannelIDsPageTTL
|
||||
}
|
||||
return &ActiveChannelIDsPageCache{c: c, ttl: ttl}
|
||||
}
|
||||
|
||||
type activeChannelIDsPageEnvelope struct {
|
||||
Schema int `json:"schema"`
|
||||
Key store.ActiveChannelIDsPageKey `json:"key"`
|
||||
ChannelIDs []int64 `json:"channel_ids"`
|
||||
}
|
||||
|
||||
func activeChannelIDsPageRedisKey(key store.ActiveChannelIDsPageKey) string {
|
||||
return fmt.Sprintf(
|
||||
"channel:active-ids:page:v1:%d:%d:%d:%d",
|
||||
key.UserID, key.Generation, key.AfterChannelID, key.Limit,
|
||||
)
|
||||
}
|
||||
|
||||
func (s *ActiveChannelIDsPageCache) GetActiveChannelIDsPage(
|
||||
ctx context.Context,
|
||||
key store.ActiveChannelIDsPageKey,
|
||||
) ([]int64, bool, error) {
|
||||
if s == nil || s.c == nil {
|
||||
return nil, false, fmt.Errorf("active channel IDs Redis cache unavailable")
|
||||
}
|
||||
if err := validateActiveChannelIDsPageKey(key); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
redisKey := activeChannelIDsPageRedisKey(key)
|
||||
raw, err := s.c.Get(ctx, redisKey).Bytes()
|
||||
if err == redis.Nil {
|
||||
return nil, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("redis get active channel IDs page: %w", err)
|
||||
}
|
||||
if len(raw) == 0 || len(raw) > activeChannelIDsPageMaxBytes {
|
||||
_ = s.c.Del(ctx, redisKey).Err()
|
||||
return nil, false, fmt.Errorf("invalid active channel IDs page size %d", len(raw))
|
||||
}
|
||||
var envelope activeChannelIDsPageEnvelope
|
||||
if err := json.Unmarshal(raw, &envelope); err != nil {
|
||||
_ = s.c.Del(ctx, redisKey).Err()
|
||||
return nil, false, fmt.Errorf("decode active channel IDs page: %w", err)
|
||||
}
|
||||
if envelope.Schema != activeChannelIDsPageSchemaV1 || envelope.Key != key {
|
||||
_ = s.c.Del(ctx, redisKey).Err()
|
||||
return nil, false, fmt.Errorf("active channel IDs page identity/schema mismatch")
|
||||
}
|
||||
if err := validateActiveChannelIDsPage(key, envelope.ChannelIDs); err != nil {
|
||||
_ = s.c.Del(ctx, redisKey).Err()
|
||||
return nil, false, err
|
||||
}
|
||||
return append([]int64(nil), envelope.ChannelIDs...), true, nil
|
||||
}
|
||||
|
||||
func (s *ActiveChannelIDsPageCache) PutActiveChannelIDsPage(
|
||||
ctx context.Context,
|
||||
key store.ActiveChannelIDsPageKey,
|
||||
channelIDs []int64,
|
||||
) error {
|
||||
if s == nil || s.c == nil {
|
||||
return fmt.Errorf("active channel IDs Redis cache unavailable")
|
||||
}
|
||||
if err := validateActiveChannelIDsPageKey(key); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateActiveChannelIDsPage(key, channelIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := json.Marshal(activeChannelIDsPageEnvelope{
|
||||
Schema: activeChannelIDsPageSchemaV1, Key: key, ChannelIDs: channelIDs,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode active channel IDs page: %w", err)
|
||||
}
|
||||
if len(raw) > activeChannelIDsPageMaxBytes {
|
||||
return fmt.Errorf("active channel IDs page exceeds %d bytes: %d", activeChannelIDsPageMaxBytes, len(raw))
|
||||
}
|
||||
if err := s.c.Set(ctx, activeChannelIDsPageRedisKey(key), raw, s.ttl).Err(); err != nil {
|
||||
return fmt.Errorf("redis set active channel IDs page: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateActiveChannelIDsPageKey(key store.ActiveChannelIDsPageKey) error {
|
||||
if key.UserID == 0 || key.Generation == 0 || key.AfterChannelID < 0 ||
|
||||
key.Limit <= 0 || key.Limit > domain.MaxSynchronousChannelDialogFanout {
|
||||
return fmt.Errorf("invalid active channel IDs page key")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateActiveChannelIDsPage(key store.ActiveChannelIDsPageKey, channelIDs []int64) error {
|
||||
if len(channelIDs) > key.Limit {
|
||||
return fmt.Errorf("active channel IDs page has %d rows, limit %d", len(channelIDs), key.Limit)
|
||||
}
|
||||
previous := key.AfterChannelID
|
||||
for _, channelID := range channelIDs {
|
||||
if channelID <= previous {
|
||||
return fmt.Errorf("active channel IDs page is not strictly ordered after %d", previous)
|
||||
}
|
||||
previous = channelID
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var _ store.ActiveChannelIDsPageCache = (*ActiveChannelIDsPageCache)(nil)
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
package redisstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
func TestActiveChannelIDsPageCacheRoundTripAndCorruptFailClosed(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 := store.ActiveChannelIDsPageKey{
|
||||
UserID: time.Now().UnixNano(), Generation: 701, AfterChannelID: 10, Limit: 1000,
|
||||
}
|
||||
redisKey := activeChannelIDsPageRedisKey(key)
|
||||
t.Cleanup(func() { _ = c.Del(ctx, redisKey).Err() })
|
||||
cache := NewActiveChannelIDsPageCache(c, time.Minute)
|
||||
want := []int64{11, 20, 30}
|
||||
if err := cache.PutActiveChannelIDsPage(ctx, key, want); err != nil {
|
||||
t.Fatalf("put: %v", err)
|
||||
}
|
||||
got, found, err := cache.GetActiveChannelIDsPage(ctx, key)
|
||||
if err != nil || !found || !slices.Equal(got, want) {
|
||||
t.Fatalf("get = %v found=%v err=%v", got, found, err)
|
||||
}
|
||||
got[0] = 999
|
||||
again, _, err := cache.GetActiveChannelIDsPage(ctx, key)
|
||||
if err != nil || !slices.Equal(again, want) {
|
||||
t.Fatalf("cached value aliased: %v err=%v", again, err)
|
||||
}
|
||||
if ttl, err := c.TTL(ctx, redisKey).Result(); err != nil || ttl <= 0 || ttl > time.Minute {
|
||||
t.Fatalf("ttl = %v err=%v", ttl, err)
|
||||
}
|
||||
if err := c.Set(ctx, redisKey, `{"schema":1,"key":{"UserID":1}}`, time.Minute).Err(); err != nil {
|
||||
t.Fatalf("seed corrupt value: %v", err)
|
||||
}
|
||||
if _, _, err := cache.GetActiveChannelIDsPage(ctx, key); err == nil {
|
||||
t.Fatal("corrupt cache value accepted")
|
||||
}
|
||||
if exists, err := c.Exists(ctx, redisKey).Result(); err != nil || exists != 0 {
|
||||
t.Fatalf("corrupt key exists=%d err=%v", exists, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActiveChannelIDsPageCacheRejectsUnorderedPage(t *testing.T) {
|
||||
cache := NewActiveChannelIDsPageCache(nil, time.Minute)
|
||||
key := store.ActiveChannelIDsPageKey{UserID: 1, Generation: -1, Limit: 1000}
|
||||
if err := validateActiveChannelIDsPage(key, []int64{2, 2}); err == nil {
|
||||
t.Fatal("duplicate channel ID accepted")
|
||||
}
|
||||
if err := cache.PutActiveChannelIDsPage(context.Background(), key, nil); err == nil {
|
||||
t.Fatal("nil Redis client accepted")
|
||||
}
|
||||
}
|
||||
|
|
@ -15,6 +15,12 @@ type BoxIDAllocator struct {
|
|||
counter counterAllocator
|
||||
}
|
||||
|
||||
var _ store.DistributedBoxIDAllocator = (*BoxIDAllocator)(nil)
|
||||
|
||||
// DistributedBoxIDAllocation marks Redis INCR reservations as safe for the
|
||||
// cross-process private-send microbatch path.
|
||||
func (*BoxIDAllocator) DistributedBoxIDAllocation() {}
|
||||
|
||||
// ChannelIDAllocator 用 Redis INCR 分配全局 channel/supergroup id。
|
||||
type ChannelIDAllocator struct {
|
||||
counter counterAllocator
|
||||
|
|
@ -25,11 +31,6 @@ type ChannelMessageIDAllocator struct {
|
|||
counter counterAllocator
|
||||
}
|
||||
|
||||
// SecretChatIDAllocator 用 Redis INCR 分配全局 secret chat id(int32 量级)。
|
||||
type SecretChatIDAllocator struct {
|
||||
counter counterAllocator
|
||||
}
|
||||
|
||||
type counterAllocator struct {
|
||||
c *redis.Client
|
||||
source store.CounterSource
|
||||
|
|
@ -113,16 +114,6 @@ func NewChannelMessageIDAllocator(c *redis.Client, source store.CounterSource) *
|
|||
}}
|
||||
}
|
||||
|
||||
// NewSecretChatIDAllocator 创建 Redis-backed secret chat id allocator。
|
||||
func NewSecretChatIDAllocator(c *redis.Client, source store.CounterSource) *SecretChatIDAllocator {
|
||||
return &SecretChatIDAllocator{counter: counterAllocator{
|
||||
c: c,
|
||||
source: source,
|
||||
key: secretChatIDKey,
|
||||
name: "secret_chat_id",
|
||||
}}
|
||||
}
|
||||
|
||||
func boxIDKey(userID int64) string {
|
||||
return fmt.Sprintf("counter:box_id:{%d}", userID)
|
||||
}
|
||||
|
|
@ -131,10 +122,6 @@ func channelIDKey(_ int64) string {
|
|||
return "counter:channel_id"
|
||||
}
|
||||
|
||||
func secretChatIDKey(_ int64) string {
|
||||
return "counter:secret_chat_id"
|
||||
}
|
||||
|
||||
func channelMessageIDKey(channelID int64) string {
|
||||
return fmt.Sprintf("counter:channel_msg_id:{%d}", channelID)
|
||||
}
|
||||
|
|
@ -144,6 +131,91 @@ func (a *BoxIDAllocator) NextBoxID(ctx context.Context, userID int64) (int, erro
|
|||
return int(v), err
|
||||
}
|
||||
|
||||
// NextBoxIDs allocates every distinct owner in one Redis pipeline. Cold
|
||||
// counters use one durable batch read and one recovery pipeline; the batch API
|
||||
// never degrades into per-user network calls.
|
||||
func (a *BoxIDAllocator) NextBoxIDs(ctx context.Context, userIDs []int64) (map[int64]int, error) {
|
||||
if a == nil || a.counter.c == nil {
|
||||
return nil, fmt.Errorf("redis box_id counter: nil client")
|
||||
}
|
||||
unique := make([]int64, 0, len(userIDs))
|
||||
keys := make([]string, 0, len(userIDs))
|
||||
seen := make(map[int64]struct{}, len(userIDs))
|
||||
for _, userID := range userIDs {
|
||||
if userID <= 0 {
|
||||
return nil, fmt.Errorf("redis box_id counter: invalid user id %d", userID)
|
||||
}
|
||||
if _, ok := seen[userID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[userID] = struct{}{}
|
||||
key, err := a.counter.validatedKey(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
unique = append(unique, userID)
|
||||
keys = append(keys, key)
|
||||
}
|
||||
if len(unique) == 0 {
|
||||
return map[int64]int{}, nil
|
||||
}
|
||||
|
||||
commands := make([]*redis.Cmd, len(unique))
|
||||
if _, err := a.counter.c.Pipelined(ctx, func(pipe redis.Pipeliner) error {
|
||||
for i, key := range keys {
|
||||
commands[i] = counterNextScript.Eval(ctx, pipe, []string{key})
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("redis batch next box_id counters: %w", err)
|
||||
}
|
||||
|
||||
out := make(map[int64]int, len(unique))
|
||||
missingUsers := make([]int64, 0, len(unique))
|
||||
missingKeys := make([]string, 0, len(unique))
|
||||
for i, command := range commands {
|
||||
value, err := command.Int64()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("redis batch next box_id counter for %d: %w", unique[i], err)
|
||||
}
|
||||
if value == missingCounterSentinel {
|
||||
missingUsers = append(missingUsers, unique[i])
|
||||
missingKeys = append(missingKeys, keys[i])
|
||||
continue
|
||||
}
|
||||
out[unique[i]] = int(value)
|
||||
}
|
||||
if len(missingUsers) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
recovered, err := a.counter.recoveredBatch(ctx, missingUsers)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
recoveryCommands := make([]*redis.Cmd, len(missingUsers))
|
||||
if _, err := a.counter.c.Pipelined(ctx, func(pipe redis.Pipeliner) error {
|
||||
for i, key := range missingKeys {
|
||||
floor, ok := recovered[missingUsers[i]]
|
||||
if !ok {
|
||||
return fmt.Errorf("durable source omitted user %d", missingUsers[i])
|
||||
}
|
||||
recoveryCommands[i] = counterRecoverNextScript.Eval(ctx, pipe, []string{key}, floor)
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("redis batch recover-next box_id counters: %w", err)
|
||||
}
|
||||
for i, command := range recoveryCommands {
|
||||
value, err := command.Int64()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("redis batch recover-next box_id counter for %d: %w", missingUsers[i], err)
|
||||
}
|
||||
out[missingUsers[i]] = int(value)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (a *BoxIDAllocator) CurrentBoxID(ctx context.Context, userID int64) (int, error) {
|
||||
v, err := a.counter.current(ctx, userID)
|
||||
return int(v), err
|
||||
|
|
@ -184,29 +256,6 @@ func (a *ChannelIDAllocator) CurrentChannelID(ctx context.Context) (int64, error
|
|||
return a.counter.current(ctx, 1)
|
||||
}
|
||||
|
||||
func (a *SecretChatIDAllocator) NextSecretChatID(ctx context.Context) (int, error) {
|
||||
v, err := a.counter.next(ctx, 1)
|
||||
return int(v), err
|
||||
}
|
||||
|
||||
// NextSecretChatIDAtLeast 把计数器至少顶到 floor 后再分配下一个 id(撞 chat_id
|
||||
// 主键自愈:Redis 快照回退或外部写库后计数器落后于 secret_chats 表最大 id)。
|
||||
func (a *SecretChatIDAllocator) NextSecretChatIDAtLeast(ctx context.Context, floor int) (int, error) {
|
||||
if a.counter.c == nil {
|
||||
return 0, fmt.Errorf("redis secret_chat_id counter: nil client")
|
||||
}
|
||||
v, err := counterNextAtLeastScript.Run(ctx, a.counter.c, []string{secretChatIDKey(1)}, floor).Int64()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("redis next-at-least secret_chat_id counter: %w", err)
|
||||
}
|
||||
return int(v), nil
|
||||
}
|
||||
|
||||
func (a *SecretChatIDAllocator) CurrentSecretChatID(ctx context.Context) (int, error) {
|
||||
v, err := a.counter.current(ctx, 1)
|
||||
return int(v), err
|
||||
}
|
||||
|
||||
func (a *ChannelMessageIDAllocator) NextChannelMessageID(ctx context.Context, channelID int64) (int, error) {
|
||||
v, err := a.counter.next(ctx, channelID)
|
||||
return int(v), err
|
||||
|
|
@ -284,3 +333,14 @@ func (a counterAllocator) recovered(ctx context.Context, userID int64) (int, err
|
|||
}
|
||||
return recovered, nil
|
||||
}
|
||||
|
||||
func (a counterAllocator) recoveredBatch(ctx context.Context, userIDs []int64) (map[int64]int, error) {
|
||||
if a.source == nil {
|
||||
return nil, fmt.Errorf("recover %s counters: missing durable source", a.name)
|
||||
}
|
||||
recovered, err := a.source.CurrentBatch(ctx, userIDs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("recover %s counters: %w", a.name, err)
|
||||
}
|
||||
return recovered, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,116 @@ func (s staticCounterSource) Current(context.Context, int64) (int, error) {
|
|||
return s.value, nil
|
||||
}
|
||||
|
||||
func (s staticCounterSource) CurrentBatch(_ context.Context, userIDs []int64) (map[int64]int, error) {
|
||||
out := make(map[int64]int, len(userIDs))
|
||||
for _, userID := range userIDs {
|
||||
out[userID] = s.value
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type recordingCounterSource struct {
|
||||
mu sync.Mutex
|
||||
values map[int64]int
|
||||
currentCalls int
|
||||
batchCalls int
|
||||
batchUsers [][]int64
|
||||
}
|
||||
|
||||
func (s *recordingCounterSource) Current(_ context.Context, userID int64) (int, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.currentCalls++
|
||||
return s.values[userID], nil
|
||||
}
|
||||
|
||||
func (s *recordingCounterSource) CurrentBatch(_ context.Context, userIDs []int64) (map[int64]int, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.batchCalls++
|
||||
s.batchUsers = append(s.batchUsers, append([]int64(nil), userIDs...))
|
||||
out := make(map[int64]int, len(userIDs))
|
||||
for _, userID := range userIDs {
|
||||
out[userID] = s.values[userID]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func TestRedisBoxAllocatorBatchPipelinesDistinctUsersAndColdRecovery(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() })
|
||||
|
||||
base := time.Now().UnixNano()
|
||||
userIDs := []int64{base, base + 1, base, base + 2}
|
||||
unique := []int64{base, base + 1, base + 2}
|
||||
for _, userID := range unique {
|
||||
userID := userID
|
||||
t.Cleanup(func() { _ = c.Del(ctx, boxIDKey(userID)).Err() })
|
||||
}
|
||||
source := &recordingCounterSource{values: map[int64]int{
|
||||
base: 100, base + 1: 200, base + 2: 300,
|
||||
}}
|
||||
boxes := NewBoxIDAllocator(c, source)
|
||||
|
||||
first, err := boxes.NextBoxIDs(ctx, userIDs)
|
||||
if err != nil {
|
||||
t.Fatalf("NextBoxIDs first: %v", err)
|
||||
}
|
||||
for i, userID := range unique {
|
||||
want := (i+1)*100 + 1
|
||||
if first[userID] != want {
|
||||
t.Fatalf("first box[%d]=%d want=%d", userID, first[userID], want)
|
||||
}
|
||||
}
|
||||
second, err := boxes.NextBoxIDs(ctx, unique)
|
||||
if err != nil {
|
||||
t.Fatalf("NextBoxIDs second: %v", err)
|
||||
}
|
||||
for i, userID := range unique {
|
||||
want := (i+1)*100 + 2
|
||||
if second[userID] != want {
|
||||
t.Fatalf("second box[%d]=%d want=%d", userID, second[userID], want)
|
||||
}
|
||||
}
|
||||
source.mu.Lock()
|
||||
defer source.mu.Unlock()
|
||||
if source.currentCalls != 0 || source.batchCalls != 1 || len(source.batchUsers) != 1 || len(source.batchUsers[0]) != len(unique) {
|
||||
t.Fatalf("source calls current=%d batch=%d users=%v", source.currentCalls, source.batchCalls, source.batchUsers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedisBoxAllocatorBatchValidatesWholeRequestBeforeMutation(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()
|
||||
key := boxIDKey(userID)
|
||||
t.Cleanup(func() { _ = c.Del(ctx, key).Err() })
|
||||
boxes := NewBoxIDAllocator(c, staticCounterSource{value: 100})
|
||||
if _, err := boxes.NextBoxIDs(ctx, []int64{userID, 0}); err == nil {
|
||||
t.Fatal("NextBoxIDs accepted an invalid user id")
|
||||
}
|
||||
if exists, err := c.Exists(ctx, key).Result(); err != nil || exists != 0 {
|
||||
t.Fatalf("validated prefix mutated redis: exists=%d err=%v", exists, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedisBoxAllocatorRecoverFromCounterSource(t *testing.T) {
|
||||
addr := os.Getenv("TELESRV_TEST_REDIS_ADDR")
|
||||
if addr == "" {
|
||||
|
|
|
|||
172
internal/store/redisstore/dialog_list_snapshot.go
Normal file
172
internal/store/redisstore/dialog_list_snapshot.go
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
package redisstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/klauspost/compress/zstd"
|
||||
"github.com/redis/go-redis/v9"
|
||||
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultDialogListSnapshotTTL = time.Hour
|
||||
dialogListSnapshotSchemaV7 = 7
|
||||
dialogListSnapshotMaxEncodedBytes = 8 << 20
|
||||
dialogListSnapshotMaxDecodedBytes = 8 << 20
|
||||
)
|
||||
|
||||
var (
|
||||
dialogListSnapshotCodecOnce sync.Once
|
||||
dialogListSnapshotEncoder *zstd.Encoder
|
||||
dialogListSnapshotDecoder *zstd.Decoder
|
||||
dialogListSnapshotCodecErr error
|
||||
)
|
||||
|
||||
// DialogListSnapshotCache stores version-addressed materialized owner snapshots.
|
||||
// PostgreSQL read-model hashes remain the authority; this cache never decides
|
||||
// whether an entry is current.
|
||||
type DialogListSnapshotCache struct {
|
||||
c *redis.Client
|
||||
ttl time.Duration
|
||||
}
|
||||
|
||||
func NewDialogListSnapshotCache(c *redis.Client, ttl time.Duration) *DialogListSnapshotCache {
|
||||
if ttl <= 0 {
|
||||
ttl = DefaultDialogListSnapshotTTL
|
||||
}
|
||||
return &DialogListSnapshotCache{c: c, ttl: ttl}
|
||||
}
|
||||
|
||||
type dialogListSnapshotEnvelope struct {
|
||||
Schema int `json:"schema"`
|
||||
Key store.DialogListSnapshotCacheKey `json:"key"`
|
||||
Value store.DialogListSnapshotCacheValue `json:"value"`
|
||||
}
|
||||
|
||||
func dialogListSnapshotKey(key store.DialogListSnapshotCacheKey) string {
|
||||
return fmt.Sprintf(
|
||||
"dialog:list:snapshot:v7:%d:%d",
|
||||
key.UserID, key.OwnerHash,
|
||||
)
|
||||
}
|
||||
|
||||
func (s *DialogListSnapshotCache) GetDialogListSnapshot(
|
||||
ctx context.Context,
|
||||
key store.DialogListSnapshotCacheKey,
|
||||
) (store.DialogListSnapshotCacheValue, bool, error) {
|
||||
if s == nil || s.c == nil {
|
||||
return store.DialogListSnapshotCacheValue{}, false, fmt.Errorf("dialog list snapshot Redis cache unavailable")
|
||||
}
|
||||
if err := validateDialogListSnapshotKey(key); err != nil {
|
||||
return store.DialogListSnapshotCacheValue{}, false, err
|
||||
}
|
||||
redisKey := dialogListSnapshotKey(key)
|
||||
raw, err := s.c.Get(ctx, redisKey).Bytes()
|
||||
if err == redis.Nil {
|
||||
return store.DialogListSnapshotCacheValue{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return store.DialogListSnapshotCacheValue{}, false, fmt.Errorf("redis get dialog list snapshot: %w", err)
|
||||
}
|
||||
if len(raw) == 0 || len(raw) > dialogListSnapshotMaxEncodedBytes {
|
||||
_ = s.c.Del(ctx, redisKey).Err()
|
||||
return store.DialogListSnapshotCacheValue{}, false, fmt.Errorf("invalid dialog list snapshot size %d", len(raw))
|
||||
}
|
||||
_, decoder, err := dialogListSnapshotCodecs()
|
||||
if err != nil {
|
||||
return store.DialogListSnapshotCacheValue{}, false, err
|
||||
}
|
||||
decoded, err := decoder.DecodeAll(raw, nil)
|
||||
if err != nil {
|
||||
_ = s.c.Del(ctx, redisKey).Err()
|
||||
return store.DialogListSnapshotCacheValue{}, false, fmt.Errorf("decompress dialog list snapshot: %w", err)
|
||||
}
|
||||
if len(decoded) == 0 || len(decoded) > dialogListSnapshotMaxDecodedBytes {
|
||||
_ = s.c.Del(ctx, redisKey).Err()
|
||||
return store.DialogListSnapshotCacheValue{}, false, fmt.Errorf("invalid decoded dialog list snapshot size %d", len(decoded))
|
||||
}
|
||||
var envelope dialogListSnapshotEnvelope
|
||||
if err := json.Unmarshal(decoded, &envelope); err != nil {
|
||||
_ = s.c.Del(ctx, redisKey).Err()
|
||||
return store.DialogListSnapshotCacheValue{}, false, fmt.Errorf("decode dialog list snapshot: %w", err)
|
||||
}
|
||||
if envelope.Schema != dialogListSnapshotSchemaV7 || envelope.Key != key || envelope.Value.DependencyHash == 0 {
|
||||
_ = s.c.Del(ctx, redisKey).Err()
|
||||
return store.DialogListSnapshotCacheValue{}, false, fmt.Errorf("dialog list snapshot identity/schema mismatch")
|
||||
}
|
||||
return envelope.Value, true, nil
|
||||
}
|
||||
|
||||
func (s *DialogListSnapshotCache) PutDialogListSnapshot(
|
||||
ctx context.Context,
|
||||
key store.DialogListSnapshotCacheKey,
|
||||
value store.DialogListSnapshotCacheValue,
|
||||
) error {
|
||||
if s == nil || s.c == nil {
|
||||
return fmt.Errorf("dialog list snapshot Redis cache unavailable")
|
||||
}
|
||||
if err := validateDialogListSnapshotKey(key); err != nil {
|
||||
return err
|
||||
}
|
||||
if value.DependencyHash == 0 {
|
||||
return fmt.Errorf("invalid dialog list snapshot dependency hash")
|
||||
}
|
||||
decoded, err := json.Marshal(dialogListSnapshotEnvelope{
|
||||
Schema: dialogListSnapshotSchemaV7,
|
||||
Key: key,
|
||||
Value: value,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode dialog list snapshot: %w", err)
|
||||
}
|
||||
if len(decoded) > dialogListSnapshotMaxDecodedBytes {
|
||||
return fmt.Errorf("dialog list snapshot exceeds %d decoded bytes: %d", dialogListSnapshotMaxDecodedBytes, len(decoded))
|
||||
}
|
||||
encoder, _, err := dialogListSnapshotCodecs()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw := encoder.EncodeAll(decoded, nil)
|
||||
if len(raw) == 0 || len(raw) > dialogListSnapshotMaxEncodedBytes {
|
||||
return fmt.Errorf("dialog list snapshot exceeds %d encoded bytes: %d", dialogListSnapshotMaxEncodedBytes, len(raw))
|
||||
}
|
||||
if err := s.c.Set(ctx, dialogListSnapshotKey(key), raw, s.ttl).Err(); err != nil {
|
||||
return fmt.Errorf("redis set dialog list snapshot: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func dialogListSnapshotCodecs() (*zstd.Encoder, *zstd.Decoder, error) {
|
||||
dialogListSnapshotCodecOnce.Do(func() {
|
||||
dialogListSnapshotEncoder, dialogListSnapshotCodecErr = zstd.NewWriter(
|
||||
nil,
|
||||
zstd.WithEncoderLevel(zstd.SpeedFastest),
|
||||
)
|
||||
if dialogListSnapshotCodecErr != nil {
|
||||
return
|
||||
}
|
||||
dialogListSnapshotDecoder, dialogListSnapshotCodecErr = zstd.NewReader(
|
||||
nil,
|
||||
zstd.WithDecoderMaxMemory(dialogListSnapshotMaxDecodedBytes),
|
||||
zstd.WithDecoderMaxWindow(dialogListSnapshotMaxDecodedBytes),
|
||||
)
|
||||
})
|
||||
if dialogListSnapshotCodecErr != nil {
|
||||
return nil, nil, fmt.Errorf("initialize dialog list snapshot codec: %w", dialogListSnapshotCodecErr)
|
||||
}
|
||||
return dialogListSnapshotEncoder, dialogListSnapshotDecoder, nil
|
||||
}
|
||||
|
||||
func validateDialogListSnapshotKey(key store.DialogListSnapshotCacheKey) error {
|
||||
if key.UserID == 0 || key.OwnerHash == 0 {
|
||||
return fmt.Errorf("invalid dialog list snapshot key")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var _ store.DialogListSnapshotCache = (*DialogListSnapshotCache)(nil)
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
package redisstore
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"os"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
func TestDialogListSnapshotCacheRoundTripAndCorruptFailClosed(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 := store.DialogListSnapshotCacheKey{
|
||||
UserID: time.Now().UnixNano(), OwnerHash: 7001,
|
||||
}
|
||||
redisKey := dialogListSnapshotKey(key)
|
||||
t.Cleanup(func() { _ = c.Del(ctx, redisKey).Err() })
|
||||
cache := NewDialogListSnapshotCache(c, time.Minute)
|
||||
want := store.DialogListSnapshotCacheValue{
|
||||
DependencyHash: 8001,
|
||||
Dialogs: []domain.Dialog{{
|
||||
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 91}, TopMessage: 7, TopMessageDate: 70,
|
||||
Draft: &domain.DialogDraft{Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 91}, Date: 71, Message: "shared draft"},
|
||||
DefaultSendAs: &domain.Peer{Type: domain.PeerTypeChannel, ID: 93},
|
||||
ChannelMember: &domain.ChannelMember{
|
||||
ChannelID: 91, UserID: 92, Role: domain.ChannelRoleAdmin, Status: domain.ChannelMemberActive,
|
||||
AvailableMinPts: 3, AdminRights: domain.ChannelAdminRights{PostMessages: true},
|
||||
},
|
||||
}},
|
||||
Messages: []domain.Message{{
|
||||
ID: 8, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 92}, Body: "private top",
|
||||
}},
|
||||
Users: []domain.User{{ID: 92, FirstName: "peer"}},
|
||||
State: domain.UpdateState{Pts: 3, Date: 4, Seq: 5},
|
||||
}
|
||||
if err := cache.PutDialogListSnapshot(ctx, key, want); err != nil {
|
||||
t.Fatalf("put: %v", err)
|
||||
}
|
||||
raw, err := c.Get(ctx, redisKey).Bytes()
|
||||
if err != nil {
|
||||
t.Fatalf("get encoded value: %v", err)
|
||||
}
|
||||
if !bytes.HasPrefix(raw, []byte{0x28, 0xb5, 0x2f, 0xfd}) {
|
||||
t.Fatalf("snapshot is not a zstd frame: prefix=%x", raw[:min(len(raw), 4)])
|
||||
}
|
||||
got, found, err := cache.GetDialogListSnapshot(ctx, key)
|
||||
if err != nil || !found || !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("get = %+v found=%v err=%v, want %+v", got, found, err, want)
|
||||
}
|
||||
if ttl, err := c.TTL(ctx, redisKey).Result(); err != nil || ttl <= 0 || ttl > time.Minute {
|
||||
t.Fatalf("ttl = %v err=%v", ttl, err)
|
||||
}
|
||||
|
||||
if err := c.Set(ctx, redisKey, "{bad json", time.Minute).Err(); err != nil {
|
||||
t.Fatalf("seed corrupt value: %v", err)
|
||||
}
|
||||
if _, _, err := cache.GetDialogListSnapshot(ctx, key); err == nil {
|
||||
t.Fatal("corrupt cache value accepted")
|
||||
}
|
||||
if exists, err := c.Exists(ctx, redisKey).Result(); err != nil || exists != 0 {
|
||||
t.Fatalf("corrupt key exists=%d err=%v, want deleted", exists, err)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue