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>
This commit is contained in:
parent
41f65bf0fc
commit
5f63240f2d
2 changed files with 103 additions and 5 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
ctx := context.Background()
|
||||
service := NewService(memory.NewChannelStore())
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue