channels: drop stale membership caches on join/leave

After channels.leaveChannel, a client that polls channels.getFullChannel kept
receiving a projection that still showed it as an active member (left=false)
until the per-(viewer,channel) RPC projection cache and the store-level member
cache lapsed on their own or the async read-model NOTIFY landed. The client
therefore kept an open compose box while every send was already rejected with
CHANNEL_PRIVATE - most visible on public forum supergroups, where getFullChannel
keeps succeeding via the preview path instead of tearing the chat down.

Every other membership-mutating path already busts these caches synchronously;
join/leave/invite/request-approval did not. Add:

- store: invalidateChannelMembershipCaches (row + member + dialog caches),
  called post-commit from JoinChannel, LeaveChannel, ImportInvite,
  InviteToChannel.
- rpc: invalidateChannelMembershipProjection (channelFullProjectionCache pair),
  called from the join/leave/invite/hide-requests handlers for every user whose
  membership changed.
This commit is contained in:
Astra 2026-09-09 13:28:50 +01:00
parent 012f8a8d0e
commit b047a90271
7 changed files with 118 additions and 1 deletions

View file

@ -103,6 +103,7 @@ func (s *ChannelStore) ImportInvite(ctx context.Context, req domain.ImportChanne
return domain.CreateChannelResult{}, fmt.Errorf("commit import channel invite: %w", err)
}
committed = true
s.invalidateChannelMembershipCaches(result.Channel.ID, req.UserID)
recipients, _ := s.ListActiveChannelMemberIDs(ctx, req.UserID, result.Channel.ID, 0)
result.Recipients = recipients
return result, nil

View file

@ -129,7 +129,9 @@ func (s *ChannelStore) InviteToChannel(ctx context.Context, channelID, inviterUs
return domain.CreateChannelResult{}, fmt.Errorf("commit invite channel: %w", err)
}
committed = true
return domain.CreateChannelResult{Channel: channel, Members: members, Message: msg, Event: event}, nil
s.invalidateChannelMembershipCaches(channelID, invitedIDs...)
recipients, _ := s.ListActiveChannelMemberIDs(ctx, inviterUserID, channelID, 0)
return domain.CreateChannelResult{Channel: channel, Members: members, Message: msg, Event: event, Recipients: recipients}, nil
}
func canInviteToChannel(channel domain.Channel, member domain.ChannelMember) bool {

View file

@ -133,6 +133,7 @@ WHERE channel_id = $1 AND user_id = $2`, channelID, userID, member.ReadInboxMaxI
return domain.CreateChannelResult{}, fmt.Errorf("commit join channel: %w", err)
}
committed = true
s.invalidateChannelMembershipCaches(channelID, userID)
recipients, _ := s.ListActiveChannelMemberIDs(ctx, userID, channelID, 0)
return domain.CreateChannelResult{Channel: channel, Members: []domain.ChannelMember{member}, Message: msg, Event: event, Recipients: recipients}, nil
}
@ -265,6 +266,11 @@ WHERE id = $1`, channelID, channel.CreatorUserID, adminsDelta); err != nil {
return domain.CreateChannelResult{}, fmt.Errorf("commit leave channel: %w", err)
}
committed = true
leftUserIDs := make([]int64, 0, len(members))
for _, m := range members {
leftUserIDs = append(leftUserIDs, m.UserID)
}
s.invalidateChannelMembershipCaches(channelID, leftUserIDs...)
recipients = append(recipients, userID)
return domain.CreateChannelResult{Channel: channel, Members: members, Message: msg, Event: event, Recipients: recipients}, nil
}

View file

@ -118,6 +118,33 @@ func (s *ChannelStore) boostCacheActive(db sqlcgen.DBTX) bool {
return s.boostCache != nil && db == s.db
}
// invalidateChannelMembershipCaches drops the in-process reads that a membership
// change (join, leave, invite, kick) makes stale for the given users. The
// ReadModelChangeListener also clears these off the async NOTIFY, but callers
// must not depend on that round-trip: a client that polls channels.getFullChannel
// right after channels.leaveChannel would otherwise keep seeing itself as an
// active member (and keep an open compose box) until the notify lands. Call it
// post-commit.
func (s *ChannelStore) invalidateChannelMembershipCaches(channelID int64, userIDs ...int64) {
if channelID == 0 {
return
}
if s.rowCache != nil {
s.rowCache.delete(channelID)
}
for _, userID := range userIDs {
if userID == 0 {
continue
}
if s.memberCache != nil {
s.memberCache.delete(channelID, userID)
}
if s.dialogCache != nil {
s.dialogCache.delete(userID, channelID)
}
}
}
// NewChannelStore 基于 pgx 连接池(或事务)创建 ChannelStore。
func NewChannelStore(db sqlcgen.DBTX, opts ...ChannelStoreOption) *ChannelStore {
s := &ChannelStore{db: db}