diff --git a/internal/app/channels/participants_cache.go b/internal/app/channels/participants_cache.go index 6a7fc820..629963c8 100644 --- a/internal/app/channels/participants_cache.go +++ b/internal/app/channels/participants_cache.go @@ -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) { 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{ userID: userID, channelID: channelID, @@ -75,15 +72,23 @@ func (s *Service) cachedParticipants(ctx context.Context, userID, channelID int6 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 { - 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) { - 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 { 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) { if filter.Kind == domain.ChannelParticipantsBots && s.bots != nil { return s.getBotParticipants(ctx, userID, channelID, offset, limit) diff --git a/internal/app/channels/service_test.go b/internal/app/channels/service_test.go index 67529051..5e5d4b6a 100644 --- a/internal/app/channels/service_test.go +++ b/internal/app/channels/service_test.go @@ -867,6 +867,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) { ctx := context.Background() service := NewService(memory.NewChannelStore())