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.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Astra 2026-09-09 13:28:50 +01:00
parent d55a97f0a5
commit 2419f47236
7 changed files with 116 additions and 0 deletions

View file

@ -290,6 +290,24 @@ func (r *Router) invalidateRPCProjectionForPeer(ownerUserID int64, peer domain.P
}
}
// invalidateChannelMembershipProjection drops the cached channels.getFullChannel
// projection for each user whose membership in channelID just changed (join,
// leave, invite, request approval). Without it a client that polls
// channels.getFullChannel right after channels.leaveChannel keeps getting a
// projection that still shows it as an active member (left=false) until the
// entry's TTL lapses, so it keeps an open compose box even though sends are
// already rejected with CHANNEL_PRIVATE.
func (r *Router) invalidateChannelMembershipProjection(channelID int64, userIDs []int64) {
if r.channelFullProjectionCache == nil || channelID == 0 {
return
}
for _, userID := range userIDs {
if userID != 0 {
r.channelFullProjectionCache.DeletePair(userID, channelID)
}
}
}
func (r *Router) invalidateRPCProjectionForChannel(channelID int64) {
if r.channelFullProjectionCache != nil {
r.channelFullProjectionCache.DeleteChannel(channelID)