Merge pull request 'channels: give getParticipants a stable Hash when read-model versions are missing' (#1) from fix/channel-participants-fallback-hash into main

Reviewed-on: astra/owpengram#1
This commit is contained in:
Astra 2026-09-09 13:27:20 +02:00
commit 7daaaf164f
2 changed files with 103 additions and 5 deletions

View file

@ -64,9 +64,6 @@ func (c *participantsReadModelCache) invalidateChannel(channelID int64) {
func (s *Service) cachedParticipants(ctx context.Context, userID, channelID int64, filter domain.ChannelParticipantsFilter, offset, limit int) (domain.ChannelParticipantList, error) { 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) filter, offset, limit = normalizeParticipantsRequest(filter, offset, limit)
if s.participantCache == nil || s.versions == nil {
return s.loadParticipants(ctx, userID, channelID, filter, offset, limit)
}
key := participantsCacheKey{ key := participantsCacheKey{
userID: userID, userID: userID,
channelID: channelID, channelID: channelID,
@ -75,15 +72,23 @@ func (s *Service) cachedParticipants(ctx context.Context, userID, channelID int6
offset: offset, offset: offset,
limit: limit, 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) hash, err := s.channelParticipantsHash(ctx, userID, channelID, key)
if err != nil { if err != nil {
return domain.ChannelParticipantList{}, err return domain.ChannelParticipantList{}, err
} }
if hash == 0 { if hash == 0 {
return s.loadParticipants(ctx, userID, channelID, filter, offset, limit) // 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) { return s.participantCache.getOrLoad(ctx, key, hash, func() (domain.ChannelParticipantList, error) {
list, err := s.loadParticipants(ctx, userID, channelID, filter, offset, limit) list, err := s.loadParticipants(ctx, userID, channelID, filter, key.offset, key.limit)
if err != nil { if err != nil {
return domain.ChannelParticipantList{}, err return domain.ChannelParticipantList{}, err
} }
@ -92,6 +97,54 @@ func (s *Service) cachedParticipants(ctx context.Context, userID, channelID int6
}) })
} }
// 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) { 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 { if filter.Kind == domain.ChannelParticipantsBots && s.bots != nil {
return s.getBotParticipants(ctx, userID, channelID, offset, limit) return s.getBotParticipants(ctx, userID, channelID, offset, limit)

View file

@ -814,6 +814,51 @@ func TestGetParticipantsCacheInvalidatesAfterAdminMutation(t *testing.T) {
} }
} }
func TestGetParticipantsFallsBackToContentHashWithoutReadModelVersions(t *testing.T) {
ctx := context.Background()
const ownerID int64 = 1001
base := &countingChannelStore{ChannelStore: memory.NewChannelStore()}
// No WithReadModelVersions: channelParticipantsHash can never build an opaque
// version hash, so the service must derive a stable one from the page itself.
service := NewService(base)
created, err := service.CreateChannel(ctx, ownerID, domain.CreateChannelRequest{
Title: "Fallback Hash",
Megagroup: true,
MemberUserIDs: []int64{1002},
Date: 1700004105,
})
if err != nil {
t.Fatalf("CreateChannel: %v", err)
}
filter := domain.ChannelParticipantsFilter{Kind: domain.ChannelParticipantsRecent}
first, err := service.GetParticipants(ctx, ownerID, created.Channel.ID, filter, 0, 20)
if err != nil {
t.Fatalf("first participants: %v", err)
}
if first.Hash == 0 {
t.Fatalf("first participants hash = 0, want stable non-zero fallback")
}
second, err := service.GetParticipants(ctx, ownerID, created.Channel.ID, filter, 0, 20)
if err != nil {
t.Fatalf("second participants: %v", err)
}
if second.Hash != first.Hash {
t.Fatalf("second hash = %d, want stable %d", second.Hash, first.Hash)
}
if _, err := service.InviteToChannel(ctx, ownerID, created.Channel.ID, []int64{1003}, 1700004106); err != nil {
t.Fatalf("InviteToChannel: %v", err)
}
third, err := service.GetParticipants(ctx, ownerID, created.Channel.ID, filter, 0, 20)
if err != nil {
t.Fatalf("third participants: %v", err)
}
if third.Hash == first.Hash {
t.Fatalf("third hash = %d, want changed after a new member joined", third.Hash)
}
}
func TestFullMegagroupAdminGrantFillsManageRanks(t *testing.T) { func TestFullMegagroupAdminGrantFillsManageRanks(t *testing.T) {
ctx := context.Background() ctx := context.Background()
service := NewService(memory.NewChannelStore()) service := NewService(memory.NewChannelStore())