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:
commit
7daaaf164f
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) {
|
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)
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue