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:
Astra 2026-09-09 12:26:30 +01:00
parent 41f65bf0fc
commit 5f63240f2d
2 changed files with 103 additions and 5 deletions

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) {
ctx := context.Background()
service := NewService(memory.NewChannelStore())