owpengram-server/internal/app/channels/participants_cache.go
Astra 5f63240f2d channels: give getParticipants a stable Hash when read-model versions are missing
cachedParticipants returned a participant page with Hash=0 whenever the
channel_base / channel_participants rows in read_model_versions were never
seeded for a channel (e.g. groups created via messages.createChat). With
Hash=0 the RPC layer can never answer channels.channelParticipantsNotModified,
so a client that polls the member list re-fetches it in a tight loop forever.

Fall back to a deterministic content hash derived from the page itself
(channel id, page key, count, and each member's id/role/status/rank) so an
unchanged member list yields an identical non-zero Hash and the client
converges. The read-model-backed path is unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 12:26:30 +01:00

222 lines
7.7 KiB
Go

package channels
import (
"context"
"encoding/binary"
"hash/fnv"
"strings"
"time"
"telesrv/internal/app/readmodel"
"telesrv/internal/domain"
"telesrv/internal/readmodelcache"
"telesrv/internal/store"
)
const (
defaultParticipantsReadModelTTL = 30 * time.Minute
participantsReadModelMaxEntries = 8192
)
type participantsCacheKey struct {
userID int64
channelID int64
kind domain.ChannelParticipantsFilterKind
query string
offset int
limit int
}
// participantsReadModelCache 由统一缓存原语承载(版本闸门 / epoch 守卫 / LRU 单条驱逐 / clone)。
// LRU 终于给 (query,offset,limit) 维度的 page-key 上界,消掉原先无界 query-string 基数增长。
type participantsReadModelCache struct {
cache *readmodelcache.Cache[participantsCacheKey, domain.ChannelParticipantList]
}
func newParticipantsReadModelCache(ttl time.Duration) *participantsReadModelCache {
if ttl <= 0 {
ttl = defaultParticipantsReadModelTTL
}
return &participantsReadModelCache{
cache: readmodelcache.New[participantsCacheKey, domain.ChannelParticipantList](readmodelcache.Config[participantsCacheKey, domain.ChannelParticipantList]{
MaxEntries: participantsReadModelMaxEntries,
TTL: ttl,
Clone: cloneParticipantList,
}),
}
}
func (c *participantsReadModelCache) getOrLoad(ctx context.Context, key participantsCacheKey, hash int64, load func() (domain.ChannelParticipantList, error)) (domain.ChannelParticipantList, error) {
if c == nil {
return load()
}
return c.cache.GetOrLoadVersioned(ctx, key, hash, load)
}
func (c *participantsReadModelCache) invalidateChannel(channelID int64) {
if c == nil || channelID == 0 {
return
}
c.cache.InvalidateWhere(func(key participantsCacheKey) bool {
return key.channelID == channelID
})
}
func (s *Service) cachedParticipants(ctx context.Context, userID, channelID int64, filter domain.ChannelParticipantsFilter, offset, limit int) (domain.ChannelParticipantList, error) {
filter, offset, limit = normalizeParticipantsRequest(filter, offset, limit)
key := participantsCacheKey{
userID: userID,
channelID: channelID,
kind: filter.Kind,
query: normalizeParticipantsQuery(filter.Query),
offset: offset,
limit: limit,
}
if s.participantCache == nil || s.versions == nil {
return s.loadParticipantsWithContentHash(ctx, userID, channelID, filter, key)
}
hash, err := s.channelParticipantsHash(ctx, userID, channelID, key)
if err != nil {
return domain.ChannelParticipantList{}, err
}
if hash == 0 {
// The read-model version hash is unavailable (e.g. a channel whose
// read_model_versions rows were never seeded). Fall back to a stable
// content hash so the RPC layer can still answer
// channels.channelParticipantsNotModified. Without a non-zero, stable
// Hash a client that polls the member list re-fetches it forever.
return s.loadParticipantsWithContentHash(ctx, userID, channelID, filter, key)
}
return s.participantCache.getOrLoad(ctx, key, hash, func() (domain.ChannelParticipantList, error) {
list, err := s.loadParticipants(ctx, userID, channelID, filter, key.offset, key.limit)
if err != nil {
return domain.ChannelParticipantList{}, err
}
list.Hash = hash
return list, nil
})
}
// loadParticipantsWithContentHash loads a participants page and, when nothing has
// assigned an opaque version hash, derives a deterministic one from the page's
// own contents so identical results keep producing an identical Hash.
func (s *Service) loadParticipantsWithContentHash(ctx context.Context, userID, channelID int64, filter domain.ChannelParticipantsFilter, key participantsCacheKey) (domain.ChannelParticipantList, error) {
list, err := s.loadParticipants(ctx, userID, channelID, filter, key.offset, key.limit)
if err != nil {
return domain.ChannelParticipantList{}, err
}
if list.Hash == 0 {
list.Hash = participantsContentHash(channelID, key, list)
}
return list, nil
}
// participantsContentHash is a stable fingerprint of a participants page: the
// channel, the page key and every returned member's client-visible identity
// (id, role, rank, status). Any change a client would render (a new member, a
// promotion, a rank edit, a kick) changes the hash; an unchanged page does not.
func participantsContentHash(channelID int64, key participantsCacheKey, list domain.ChannelParticipantList) int64 {
h := fnv.New64a()
var buf [8]byte
writeUint := func(v uint64) {
binary.LittleEndian.PutUint64(buf[:], v)
_, _ = h.Write(buf[:])
}
writeStr := func(s string) {
_, _ = h.Write([]byte(s))
_, _ = h.Write([]byte{0})
}
writeUint(uint64(channelID))
writeStr(string(key.kind))
writeStr(key.query)
writeUint(uint64(key.offset))
writeUint(uint64(key.limit))
writeUint(uint64(int64(list.Count)))
for _, p := range list.Participants {
writeUint(uint64(p.UserID))
writeStr(string(p.Role))
writeStr(string(p.Status))
writeStr(p.Rank)
}
sum := int64(h.Sum64() & 0x7fffffffffffffff)
if sum == 0 {
return 1
}
return sum
}
func (s *Service) loadParticipants(ctx context.Context, userID, channelID int64, filter domain.ChannelParticipantsFilter, offset, limit int) (domain.ChannelParticipantList, error) {
if filter.Kind == domain.ChannelParticipantsBots && s.bots != nil {
return s.getBotParticipants(ctx, userID, channelID, offset, limit)
}
return s.channels.GetParticipants(ctx, userID, channelID, filter, offset, limit)
}
func (s *Service) channelParticipantsHash(ctx context.Context, userID, channelID int64, key participantsCacheKey) (int64, error) {
keys := []store.ReadModelKey{
{Model: readmodel.ModelChannelBase, OwnerUserID: 0, PeerType: domain.PeerTypeChannel, PeerID: channelID},
{Model: readmodel.ModelChannelParticipants, OwnerUserID: 0, PeerType: domain.PeerTypeChannel, PeerID: channelID},
{Model: readmodel.ModelChannelMember, OwnerUserID: userID, PeerType: domain.PeerTypeChannel, PeerID: channelID},
{Model: readmodel.ModelContactAccount, OwnerUserID: userID, PeerType: domain.PeerTypeUser, PeerID: userID},
}
rows, err := s.versions.ReadModelHashes(ctx, keys)
if err != nil {
return 0, err
}
base := rows[keys[0]]
participants := rows[keys[1]]
if base == 0 || participants == 0 {
return 0, nil
}
return readmodel.MixHashes(base, participants, rows[keys[2]], rows[keys[3]], participantsPageHash(key)), nil
}
func normalizeParticipantsRequest(filter domain.ChannelParticipantsFilter, offset, limit int) (domain.ChannelParticipantsFilter, int, int) {
if filter.Kind == "" {
filter.Kind = domain.ChannelParticipantsRecent
}
filter.Query = normalizeParticipantsQuery(filter.Query)
if offset < 0 {
offset = 0
}
if offset > domain.MaxChannelParticipantsOffset {
offset = domain.MaxChannelParticipantsOffset
}
if limit <= 0 || limit > domain.MaxChannelParticipantsLimit {
limit = domain.MaxChannelParticipantsLimit
}
return filter, offset, limit
}
func normalizeParticipantsQuery(query string) string {
return strings.ToLower(strings.TrimSpace(query))
}
func participantsPageHash(key participantsCacheKey) int64 {
h := fnv.New64a()
_, _ = h.Write([]byte(key.kind))
_, _ = h.Write([]byte{0})
_, _ = h.Write([]byte(key.query))
var buf [16]byte
binary.LittleEndian.PutUint64(buf[0:8], uint64(key.offset))
binary.LittleEndian.PutUint64(buf[8:16], uint64(key.limit))
_, _ = h.Write(buf[:])
sum := int64(h.Sum64() & 0x7fffffffffffffff)
if sum == 0 {
return 1
}
return sum
}
func cloneParticipantList(in domain.ChannelParticipantList) domain.ChannelParticipantList {
in.Channel = cloneChannel(in.Channel)
in.Participants = append([]domain.ChannelMember(nil), in.Participants...)
if len(in.Users) > 0 {
in.Users = make([]domain.User, len(in.Users))
for i, user := range in.Users {
user.PhotoStripped = append([]byte(nil), user.PhotoStripped...)
in.Users[i] = user
}
}
return in
}