owpengram-server/internal/rpc/rpc_projection_cache.go
Astra 55a6e0bb35 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.
2026-09-09 13:28:50 +01:00

350 lines
11 KiB
Go

package rpc
import (
"time"
"github.com/iamxvbaba/td/tg"
"telesrv/internal/domain"
)
const (
rpcProjectionCacheTTL = 30 * time.Minute
rpcProjectionCacheMaxEntries = 100000
)
// userFullProjectionCache / peerSettingsProjectionCache / channelFullProjectionCache 是三个
// 结构同形的 per-viewer TTL 投影缓存,统一收敛到 projectionCache[K,V](epoch 守卫 / LRU 单条
// 驱逐 / TTL / clone 由 readmodelcache 原语承载)。各自仅在键/值类型、clone、失效维度上不同。
type userFullProjectionKey struct {
viewerUserID int64
targetUserID int64
}
type userFullProjectionCache struct {
*projectionCache[userFullProjectionKey, tg.UserFull]
}
func newUserFullProjectionCache(clock func() time.Time) *userFullProjectionCache {
return &userFullProjectionCache{
newProjectionCache[userFullProjectionKey, tg.UserFull](rpcProjectionCacheMaxEntries, rpcProjectionCacheTTL, clock, cloneUserFull),
}
}
func (c *userFullProjectionCache) Lookup(viewerUserID, targetUserID int64) (tg.UserFull, bool) {
if c == nil || viewerUserID == 0 || targetUserID == 0 {
return tg.UserFull{}, false
}
return c.lookup(userFullProjectionKey{viewerUserID: viewerUserID, targetUserID: targetUserID})
}
// StoreIfEpoch 仅在 epoch 未变(构建期间没有失效)时写入。
func (c *userFullProjectionCache) StoreIfEpoch(viewerUserID, targetUserID int64, full tg.UserFull, loadEpoch uint64) {
if c == nil || viewerUserID == 0 || targetUserID == 0 {
return
}
c.storeIfEpoch(userFullProjectionKey{viewerUserID: viewerUserID, targetUserID: targetUserID}, full, loadEpoch)
}
func (c *userFullProjectionCache) DeleteViewer(viewerUserID int64) {
if c == nil || viewerUserID == 0 {
return
}
c.deleteWhere(func(k userFullProjectionKey) bool { return k.viewerUserID == viewerUserID })
}
func (c *userFullProjectionCache) DeleteTarget(targetUserID int64) {
if c == nil || targetUserID == 0 {
return
}
c.deleteWhere(func(k userFullProjectionKey) bool { return k.targetUserID == targetUserID })
}
func (c *userFullProjectionCache) DeletePair(viewerUserID, targetUserID int64) {
if c == nil || viewerUserID == 0 || targetUserID == 0 {
return
}
c.deleteKey(userFullProjectionKey{viewerUserID: viewerUserID, targetUserID: targetUserID})
}
type peerSettingsProjectionKey struct {
viewerUserID int64
peer domain.Peer
}
type peerSettingsProjectionCache struct {
// domain.PeerSettings 为扁平值(无共享可变字段),故 Clone=nil。
*projectionCache[peerSettingsProjectionKey, domain.PeerSettings]
}
func newPeerSettingsProjectionCache(clock func() time.Time) *peerSettingsProjectionCache {
return &peerSettingsProjectionCache{
newProjectionCache[peerSettingsProjectionKey, domain.PeerSettings](rpcProjectionCacheMaxEntries, rpcProjectionCacheTTL, clock, nil),
}
}
func (c *peerSettingsProjectionCache) Lookup(viewerUserID int64, peer domain.Peer) (domain.PeerSettings, bool) {
if c == nil || viewerUserID == 0 || peer.ID == 0 {
return domain.PeerSettings{}, false
}
return c.lookup(peerSettingsProjectionKey{viewerUserID: viewerUserID, peer: peer})
}
// StoreIfEpoch 仅在 epoch 未变(构建期间没有失效)时写入。
func (c *peerSettingsProjectionCache) StoreIfEpoch(viewerUserID int64, peer domain.Peer, settings domain.PeerSettings, loadEpoch uint64) {
if c == nil || viewerUserID == 0 || peer.ID == 0 {
return
}
c.storeIfEpoch(peerSettingsProjectionKey{viewerUserID: viewerUserID, peer: peer}, settings, loadEpoch)
}
func (c *peerSettingsProjectionCache) DeleteViewer(viewerUserID int64) {
if c == nil || viewerUserID == 0 {
return
}
c.deleteWhere(func(k peerSettingsProjectionKey) bool { return k.viewerUserID == viewerUserID })
}
func (c *peerSettingsProjectionCache) DeletePeer(peer domain.Peer) {
if c == nil || peer.ID == 0 {
return
}
c.deleteWhere(func(k peerSettingsProjectionKey) bool { return k.peer == peer })
}
func (c *peerSettingsProjectionCache) DeletePair(viewerUserID int64, peer domain.Peer) {
if c == nil || viewerUserID == 0 || peer.ID == 0 {
return
}
c.deleteKey(peerSettingsProjectionKey{viewerUserID: viewerUserID, peer: peer})
}
type channelFullProjectionKey struct {
viewerUserID int64
channelID int64
}
type channelFullProjection struct {
accessHash int64
canChangeInfo bool
full tg.ChannelFull
chats []tg.ChatClass
userIDs []int64
}
type channelFullProjectionCache struct {
*projectionCache[channelFullProjectionKey, channelFullProjection]
}
func newChannelFullProjectionCache(clock func() time.Time) *channelFullProjectionCache {
return &channelFullProjectionCache{
newProjectionCache[channelFullProjectionKey, channelFullProjection](rpcProjectionCacheMaxEntries, rpcProjectionCacheTTL, clock, cloneChannelFullProjection),
}
}
func (c *channelFullProjectionCache) Lookup(viewerUserID, channelID int64) (channelFullProjection, bool) {
if c == nil || viewerUserID == 0 || channelID == 0 {
return channelFullProjection{}, false
}
return c.lookup(channelFullProjectionKey{viewerUserID: viewerUserID, channelID: channelID})
}
// StoreIfEpoch 仅在 epoch 未变(构建期间没有失效)时写入。
func (c *channelFullProjectionCache) StoreIfEpoch(viewerUserID, channelID int64, value channelFullProjection, loadEpoch uint64) {
if c == nil || viewerUserID == 0 || channelID == 0 {
return
}
c.storeIfEpoch(channelFullProjectionKey{viewerUserID: viewerUserID, channelID: channelID}, value, loadEpoch)
}
func (c *channelFullProjectionCache) DeleteViewer(viewerUserID int64) {
if c == nil || viewerUserID == 0 {
return
}
c.deleteWhere(func(k channelFullProjectionKey) bool { return k.viewerUserID == viewerUserID })
}
func (c *channelFullProjectionCache) DeleteChannel(channelID int64) {
if c == nil || channelID == 0 {
return
}
c.deleteWhere(func(k channelFullProjectionKey) bool { return k.channelID == channelID })
}
func (c *channelFullProjectionCache) DeletePair(viewerUserID, channelID int64) {
if c == nil || viewerUserID == 0 || channelID == 0 {
return
}
c.deleteKey(channelFullProjectionKey{viewerUserID: viewerUserID, channelID: channelID})
}
func cloneChannelFullProjection(in channelFullProjection) channelFullProjection {
return channelFullProjection{
accessHash: in.accessHash,
canChangeInfo: in.canChangeInfo,
full: cloneChannelFull(in.full),
chats: cloneChatClasses(in.chats),
userIDs: cloneInt64s(in.userIDs),
}
}
func cloneUserFull(in tg.UserFull) tg.UserFull {
out := in
out.BotInfo = cloneBotInfo(in.BotInfo)
return out
}
func cloneChannelFull(in tg.ChannelFull) tg.ChannelFull {
out := in
out.BotInfo = cloneBotInfos(in.BotInfo)
out.PendingSuggestions = append([]string(nil), in.PendingSuggestions...)
out.RecentRequesters = cloneInt64s(in.RecentRequesters)
out.ExportedInvite = cloneExportedChatInvite(in.ExportedInvite)
return out
}
func cloneExportedChatInvite(in tg.ExportedChatInviteClass) tg.ExportedChatInviteClass {
switch v := in.(type) {
case *tg.ChatInviteExported:
out := *v
return &out
default:
return v
}
}
func cloneBotInfo(in tg.BotInfo) tg.BotInfo {
out := in
out.Commands = append([]tg.BotCommand(nil), in.Commands...)
return out
}
func cloneChatClass(in tg.ChatClass) tg.ChatClass {
switch v := in.(type) {
case *tg.Channel:
out := *v
out.RestrictionReason = append([]tg.RestrictionReason(nil), v.RestrictionReason...)
out.Usernames = append([]tg.Username(nil), v.Usernames...)
return &out
case *tg.Chat:
out := *v
return &out
case *tg.ChannelForbidden:
out := *v
return &out
case *tg.ChatForbidden:
out := *v
return &out
case *tg.ChatEmpty:
out := *v
return &out
default:
return in
}
}
func cloneChatClasses(in []tg.ChatClass) []tg.ChatClass {
if len(in) == 0 {
return nil
}
out := make([]tg.ChatClass, 0, len(in))
for _, chat := range in {
out = append(out, cloneChatClass(chat))
}
return out
}
func (r *Router) invalidateRPCProjectionForViewer(viewerUserID int64) {
if r.userFullProjectionCache != nil {
r.userFullProjectionCache.DeleteViewer(viewerUserID)
}
if r.peerSettingsProjectionCache != nil {
r.peerSettingsProjectionCache.DeleteViewer(viewerUserID)
}
if r.channelFullProjectionCache != nil {
r.channelFullProjectionCache.DeleteViewer(viewerUserID)
}
}
func (r *Router) invalidateRPCProjectionForUser(userID int64) {
if r.userFullProjectionCache != nil {
r.userFullProjectionCache.DeleteTarget(userID)
r.userFullProjectionCache.DeleteViewer(userID)
}
if r.peerSettingsProjectionCache != nil {
r.peerSettingsProjectionCache.DeleteViewer(userID)
r.peerSettingsProjectionCache.DeletePeer(domain.Peer{Type: domain.PeerTypeUser, ID: userID})
}
}
func (r *Router) invalidateRPCProjectionForPeer(ownerUserID int64, peer domain.Peer) {
if r.userFullProjectionCache != nil && peer.Type == domain.PeerTypeUser {
r.userFullProjectionCache.DeletePair(ownerUserID, peer.ID)
}
if r.peerSettingsProjectionCache != nil {
r.peerSettingsProjectionCache.DeletePair(ownerUserID, peer)
}
if r.channelFullProjectionCache != nil && peer.Type == domain.PeerTypeChannel {
r.channelFullProjectionCache.DeletePair(ownerUserID, peer.ID)
}
}
// 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)
}
if r.peerSettingsProjectionCache != nil {
r.peerSettingsProjectionCache.DeletePeer(domain.Peer{Type: domain.PeerTypeChannel, ID: channelID})
}
}
func (r *Router) flushRPCProjectionCache() {
if r.userFullProjectionCache != nil {
r.userFullProjectionCache.Clear()
}
if r.peerSettingsProjectionCache != nil {
r.peerSettingsProjectionCache.Clear()
}
if r.channelFullProjectionCache != nil {
r.channelFullProjectionCache.Clear()
}
}
func (r *Router) InvalidateRPCProjectionReadModelForViewer(viewerUserID int64) {
r.invalidateRPCProjectionForViewer(viewerUserID)
}
func (r *Router) InvalidateRPCProjectionReadModelForUser(userID int64) {
r.invalidateRPCProjectionForUser(userID)
}
func (r *Router) InvalidateRPCProjectionReadModelForPeer(ownerUserID int64, peer domain.Peer) {
r.invalidateRPCProjectionForPeer(ownerUserID, peer)
}
func (r *Router) InvalidateRPCProjectionReadModelForChannel(channelID int64) {
r.invalidateRPCProjectionForChannel(channelID)
}
func (r *Router) FlushRPCProjectionReadModel() {
r.flushRPCProjectionCache()
}