owpengram-server/internal/store/postgres/channel_top_message_cache.go
2026-09-01 12:06:31 +03:00

226 lines
7.7 KiB
Go

package postgres
import (
"context"
"telesrv/internal/domain"
"telesrv/internal/readmodelcache"
"telesrv/internal/store/postgres/sqlcgen"
)
// ChannelTopMessageCache stores the viewer-independent channel_messages row
// used as a dialog's top payload. Viewer overlays (mentioned/media_unread,
// normal/paid reactions) are deliberately applied after this cache.
//
// channel_base is the dependency token: edits/deletes/new tops and any other
// mutation that can alter the visible top payload bump it. The read-model
// listener invalidates every cached key for that channel and flushes on
// reconnect, while the cache epoch prevents a pre-invalidation batch load from
// being written back afterwards.
type ChannelTopMessageCache struct {
cache *readmodelcache.Cache[channelMessageLookupKey, domain.ChannelMessage]
reactionPresence *readmodelcache.Cache[channelMessageLookupKey, channelTopReactionPresence]
}
type channelTopReactionPresence struct {
Normal bool
Paid bool
}
func (p channelTopReactionPresence) any() bool { return p.Normal || p.Paid }
func NewChannelTopMessageCache(max int) *ChannelTopMessageCache {
cache := readmodelcache.New[channelMessageLookupKey, domain.ChannelMessage](readmodelcache.Config[channelMessageLookupKey, domain.ChannelMessage]{
MaxEntries: max,
Clone: cloneChannelTopMessage,
})
if cache == nil {
return nil
}
return &ChannelTopMessageCache{
cache: cache,
reactionPresence: readmodelcache.New[channelMessageLookupKey, channelTopReactionPresence](readmodelcache.Config[channelMessageLookupKey, channelTopReactionPresence]{
MaxEntries: max,
}),
}
}
func (c *ChannelTopMessageCache) getOrLoadBatch(
ctx context.Context,
keys []channelMessageLookupKey,
load func(context.Context, []channelMessageLookupKey) (map[channelMessageLookupKey]domain.ChannelMessage, error),
) (map[channelMessageLookupKey]domain.ChannelMessage, error) {
if c == nil {
return load(ctx, keys)
}
return c.cache.GetOrLoadBatch(
ctx,
keys,
func(channelMessageLookupKey) (int64, bool) { return 0, true },
func(ctx context.Context, missing []channelMessageLookupKey) (map[channelMessageLookupKey]domain.ChannelMessage, error) {
loaded, err := load(ctx, missing)
if err != nil {
return nil, err
}
for _, key := range missing {
if _, ok := loaded[key]; !ok {
loaded[key] = domain.ChannelMessage{}
}
}
return loaded, nil
},
)
}
func (c *ChannelTopMessageCache) deleteChannel(channelID int64) {
if c == nil || channelID == 0 {
return
}
c.cache.InvalidateWhere(func(key channelMessageLookupKey) bool { return key.channelID == channelID })
c.reactionPresence.InvalidateWhere(func(key channelMessageLookupKey) bool { return key.channelID == channelID })
}
func (c *ChannelTopMessageCache) flush() {
if c == nil {
return
}
c.cache.Flush()
c.reactionPresence.Flush()
}
// reactionPresenceFor returns only a shared existence bit. It never caches
// counts, chosen state, recent order or paid identities, all of which remain
// viewer/current-data projections. A negative bit is enough to skip three
// guaranteed-empty reaction queries for the many top messages with no
// reactions at all.
func (c *ChannelTopMessageCache) reactionPresenceFor(
ctx context.Context,
db sqlcgen.DBTX,
messages []domain.ChannelMessage,
) (map[channelMessageLookupKey]channelTopReactionPresence, error) {
keys := make([]channelMessageLookupKey, 0, len(messages))
for _, msg := range messages {
if msg.ChannelID == 0 || msg.ID <= 0 || domain.IsChannelHistoryClearMessage(msg) {
continue
}
keys = append(keys, channelMessageLookupKey{channelID: msg.ChannelID, id: msg.ID})
}
if len(keys) == 0 {
return map[channelMessageLookupKey]channelTopReactionPresence{}, nil
}
return c.reactionPresence.GetOrLoadBatch(
ctx,
keys,
func(channelMessageLookupKey) (int64, bool) { return 0, true },
func(ctx context.Context, missing []channelMessageLookupKey) (map[channelMessageLookupKey]channelTopReactionPresence, error) {
channelIDs := make([]int64, 0, len(missing))
messageIDs := make([]int32, 0, len(missing))
for _, key := range missing {
channelIDs = append(channelIDs, key.channelID)
messageIDs = append(messageIDs, pgInt32NonNegative(key.id))
}
rows, err := db.Query(ctx, `
WITH requested AS (
SELECT channel_id, message_id
FROM unnest($1::bigint[], $2::int[]) AS r(channel_id, message_id)
)
SELECT r.channel_id, r.message_id,
EXISTS (
SELECT 1 FROM channel_message_reactions normal
WHERE normal.channel_id=r.channel_id AND normal.message_id=r.message_id
),
EXISTS (
SELECT 1 FROM channel_message_paid_reactions paid
WHERE paid.channel_id=r.channel_id AND paid.message_id=r.message_id
)
FROM requested r`, channelIDs, messageIDs)
if err != nil {
return nil, err
}
defer rows.Close()
out := make(map[channelMessageLookupKey]channelTopReactionPresence, len(missing))
for rows.Next() {
var key channelMessageLookupKey
var presence channelTopReactionPresence
if err := rows.Scan(&key.channelID, &key.id, &presence.Normal, &presence.Paid); err != nil {
return nil, err
}
out[key] = presence
}
if err := rows.Err(); err != nil {
return nil, err
}
for _, key := range missing {
if _, ok := out[key]; !ok {
out[key] = channelTopReactionPresence{}
}
}
return out, nil
},
)
}
// cloneChannelTopMessage isolates every mutable field that is enriched by the
// dialog projection path. Media is an immutable decoded storage snapshot; the
// hot path only reads it and never mutates its nested objects.
func cloneChannelTopMessage(msg domain.ChannelMessage) domain.ChannelMessage {
msg.Entities = append([]domain.MessageEntity(nil), msg.Entities...)
msg.ReplyTo = cloneMessageReply(msg.ReplyTo)
msg.Forward = cloneMessageForward(msg.Forward)
msg.Action = cloneChannelMessageAction(msg.Action)
if msg.SendAs != nil {
peer := *msg.SendAs
msg.SendAs = &peer
}
if msg.SuggestedPost != nil {
suggested := *msg.SuggestedPost
if suggested.Price != nil {
price := *suggested.Price
suggested.Price = &price
}
msg.SuggestedPost = &suggested
}
if msg.Discussion != nil {
discussion := *msg.Discussion
msg.Discussion = &discussion
}
if msg.Replies != nil {
replies := *msg.Replies
replies.RecentRepliers = append([]domain.Peer(nil), msg.Replies.RecentRepliers...)
msg.Replies = &replies
}
if msg.Reactions != nil {
reactions := *msg.Reactions
reactions.Results = append([]domain.ChannelMessageReactionCount(nil), msg.Reactions.Results...)
reactions.Recent = append([]domain.ChannelMessagePeerReaction(nil), msg.Reactions.Recent...)
msg.Reactions = &reactions
}
if msg.RichMessage != nil {
rich := *msg.RichMessage
rich.Blocks = append([]byte(nil), msg.RichMessage.Blocks...)
rich.Photos = append([]domain.Photo(nil), msg.RichMessage.Photos...)
rich.Documents = append([]domain.Document(nil), msg.RichMessage.Documents...)
rich.BotAPIProjection = append([]byte(nil), msg.RichMessage.BotAPIProjection...)
msg.RichMessage = &rich
}
if msg.ReplyMarkup != nil {
markup := *msg.ReplyMarkup
if msg.ReplyMarkup.Inline != nil {
markup.Inline = make([][]domain.MarkupButton, len(msg.ReplyMarkup.Inline))
for i, row := range msg.ReplyMarkup.Inline {
markup.Inline[i] = append([]domain.MarkupButton(nil), row...)
for j := range markup.Inline[i] {
markup.Inline[i][j].Data = append([]byte(nil), row[j].Data...)
}
}
}
if msg.ReplyMarkup.Keyboard != nil {
markup.Keyboard = make([][]domain.MarkupButton, len(msg.ReplyMarkup.Keyboard))
for i, row := range msg.ReplyMarkup.Keyboard {
markup.Keyboard[i] = append([]domain.MarkupButton(nil), row...)
}
}
msg.ReplyMarkup = &markup
}
return msg
}