owpengram-server/internal/app/channels/active_ids_cache.go
2026-09-01 12:06:31 +03:00

200 lines
6.1 KiB
Go

package channels
import (
"context"
"errors"
"fmt"
"time"
"telesrv/internal/app/readmodel"
"telesrv/internal/domain"
"telesrv/internal/readmodelcache"
"telesrv/internal/store"
)
const (
defaultActiveChannelIDsReadModelTTL = 24 * time.Hour
activeChannelIDsReadModelMaxEntries = 32768
activeChannelIDsNoVersionHash = -1
activeChannelIDsStableCutAttempts = 2
)
var errActiveChannelIDsGenerationChanged = errors.New("active channel IDs generation changed")
// ActiveChannelIDsReadModelMetrics records bounded shared-cache outcomes.
// User IDs and page selectors are deliberately excluded.
type ActiveChannelIDsReadModelMetrics interface {
ActiveChannelIDsCache(outcome string)
}
type activeChannelIDsCacheKey struct {
userID int64
afterChannelID int64
limit int
}
// activeChannelIDsReadModelCache 由统一缓存原语承载(版本闸门 / epoch 守卫 / LRU / clone)。
// 无版本(version 缺失)时用 activeChannelIDsNoVersionHash 哨兵作版本,仍享 TTL+epoch 失效。
type activeChannelIDsReadModelCache struct {
cache *readmodelcache.Cache[activeChannelIDsCacheKey, []int64]
}
func newActiveChannelIDsReadModelCache(maxEntries int, ttl time.Duration) *activeChannelIDsReadModelCache {
if maxEntries <= 0 {
maxEntries = activeChannelIDsReadModelMaxEntries
}
if ttl <= 0 {
ttl = defaultActiveChannelIDsReadModelTTL
}
return &activeChannelIDsReadModelCache{
cache: readmodelcache.New[activeChannelIDsCacheKey, []int64](readmodelcache.Config[activeChannelIDsCacheKey, []int64]{
MaxEntries: maxEntries,
TTL: ttl,
Clone: cloneInt64s,
}),
}
}
func (c *activeChannelIDsReadModelCache) getOrLoad(ctx context.Context, key activeChannelIDsCacheKey, hash int64, load func() ([]int64, error)) ([]int64, error) {
if c == nil {
return load()
}
return c.cache.GetOrLoadVersioned(ctx, key, hash, load)
}
func (c *activeChannelIDsReadModelCache) invalidateUsers(userIDs ...int64) {
if c == nil || len(userIDs) == 0 {
return
}
seen := make(map[int64]struct{}, len(userIDs))
for _, userID := range userIDs {
if userID != 0 {
seen[userID] = struct{}{}
}
}
if len(seen) == 0 {
return
}
c.cache.InvalidateWhere(func(k activeChannelIDsCacheKey) bool {
_, ok := seen[k.userID]
return ok
})
}
func (s *Service) cachedActiveChannelIDsForUser(ctx context.Context, userID, afterChannelID int64, limit int) ([]int64, error) {
if s.activeIDsShared == nil {
if s.activeIDsCache == nil || s.versions == nil {
return s.channels.ListActiveChannelIDsForUser(ctx, userID, afterChannelID, limit)
}
hash, err := s.activeChannelIDsGeneration(ctx, userID)
if err != nil {
return nil, err
}
key := activeChannelIDsCacheKey{userID: userID, afterChannelID: afterChannelID, limit: limit}
return s.activeIDsCache.getOrLoad(ctx, key, hash, func() ([]int64, error) {
return s.channels.ListActiveChannelIDsForUser(ctx, userID, afterChannelID, limit)
})
}
if s.activeIDsCache == nil || s.versions == nil || s.activeIDsLoader == nil {
return nil, errors.New("shared active channel IDs read model is incompletely configured")
}
key := activeChannelIDsCacheKey{userID: userID, afterChannelID: afterChannelID, limit: limit}
for attempt := 0; attempt < activeChannelIDsStableCutAttempts; attempt++ {
generation, err := s.activeChannelIDsGeneration(ctx, userID)
if err != nil {
return nil, err
}
channelIDs, err := s.activeIDsCache.getOrLoad(ctx, key, generation, func() ([]int64, error) {
return s.loadSharedActiveChannelIDsPage(ctx, key, generation)
})
if errors.Is(err, errActiveChannelIDsGenerationChanged) {
s.recordActiveChannelIDsCache("generation_retry")
continue
}
if err != nil {
return nil, err
}
currentGeneration, err := s.activeChannelIDsGeneration(ctx, userID)
if err != nil {
return nil, err
}
if currentGeneration != generation {
s.recordActiveChannelIDsCache("generation_retry")
s.activeIDsCache.invalidateUsers(userID)
continue
}
s.recordActiveChannelIDsCache("served")
return channelIDs, nil
}
return nil, errActiveChannelIDsGenerationChanged
}
func (s *Service) activeChannelIDsGeneration(ctx context.Context, userID int64) (int64, error) {
if s == nil || s.versions == nil {
return 0, errors.New("active channel IDs read model requires durable versions")
}
hash, ok, err := s.versions.ReadModelHash(ctx, readmodel.ModelChannelActiveIDs, userID, domain.PeerTypeUser, userID)
if err != nil {
return 0, err
}
if !ok || hash == 0 {
return activeChannelIDsNoVersionHash, nil
}
return hash, nil
}
func (s *Service) loadSharedActiveChannelIDsPage(
ctx context.Context,
key activeChannelIDsCacheKey,
generation int64,
) ([]int64, error) {
sharedKey := store.ActiveChannelIDsPageKey{
UserID: key.userID, Generation: generation,
AfterChannelID: key.afterChannelID, Limit: key.limit,
}
channelIDs, found, err := s.activeIDsShared.GetActiveChannelIDsPage(ctx, sharedKey)
if err != nil {
s.recordActiveChannelIDsCache("read_error")
return nil, err
}
if found {
s.recordActiveChannelIDsCache("hit")
return channelIDs, nil
}
s.recordActiveChannelIDsCache("miss")
channelIDs, err = s.activeIDsLoader.ListActiveChannelIDsForUser(
ctx, key.userID, key.afterChannelID, key.limit,
)
if err != nil {
return nil, err
}
if generation == activeChannelIDsNoVersionHash && len(channelIDs) != 0 {
return nil, fmt.Errorf("active channel IDs generation missing for non-empty owner %d", key.userID)
}
currentGeneration, err := s.activeChannelIDsGeneration(ctx, key.userID)
if err != nil {
return nil, err
}
if currentGeneration != generation {
return nil, errActiveChannelIDsGenerationChanged
}
if err := s.activeIDsShared.PutActiveChannelIDsPage(ctx, sharedKey, channelIDs); err != nil {
s.recordActiveChannelIDsCache("write_error")
return nil, err
}
s.recordActiveChannelIDsCache("fill")
return channelIDs, nil
}
func (s *Service) recordActiveChannelIDsCache(outcome string) {
if s != nil && s.activeIDsMetrics != nil {
s.activeIDsMetrics.ActiveChannelIDsCache(outcome)
}
}
func cloneInt64s(in []int64) []int64 {
if len(in) == 0 {
return nil
}
return append([]int64(nil), in...)
}