Ctrl K
ComunidadGuías…

internal/store/postgres/channel_row_cache.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package postgres

import (
	"context"

	"telesrv/internal/domain"
	"telesrv/internal/readmodelcache"
)

// ChannelRowCache is a strongly consistent in-process cache of the "shared channel row"
// (one channels-table row → domain.Channel), backed by the unified cache primitive
// readmodelcache.Cache (per-entry LRU eviction / epoch guard / built-in singleflight).
//
// It caches only the viewer-agnostic, infrequently changing channel row; the per-viewer member
// (permissions/read state) is isolated separately by ChannelMemberCache keyed by
// (channel_id,user_id), and dialog (top/unread) and boost are still queried live from PG.
//
// Consistency relies on real-time invalidation through the unified read-model PG LISTEN/NOTIFY
// (see ReadModelChangeListener) rather than a TTL: the read-model trigger on the channels table
// publishes a channel_base event on commit, and the listener deletes the corresponding entry on
// receipt; on (re)connect the listener flushes the whole table, covering notifications lost in
// the disconnect window. There is no TTL, so the epoch guard is the only safeguard against
// permanent staleness (built into the primitive).
//
// It is consumed only on the pool handle (not in a transaction) (see ChannelStore.cacheActive):
// inside a transaction everything is read live to guarantee read-your-write.
type ChannelRowCache struct {
	cache *readmodelcache.Cache[int64, domain.Channel]
}

// NewChannelRowCache creates a channel row cache with capacity max; max<=0 returns nil
// (meaning disabled, and callers skip on nil).
func NewChannelRowCache(max int) *ChannelRowCache {
	cache := readmodelcache.New[int64, domain.Channel](readmodelcache.Config[int64, domain.Channel]{
		MaxEntries: max,
		Clone:      cloneChannelRow,
	})
	if cache == nil {
		return nil
	}
	return &ChannelRowCache{cache: cache}
}

func (c *ChannelRowCache) get(id int64) (domain.Channel, bool) {
	if c == nil || id == 0 {
		return domain.Channel{}, false
	}
	return c.cache.Peek(id)
}

func (c *ChannelRowCache) getOrLoad(ctx context.Context, id int64, load func() (domain.Channel, error)) (domain.Channel, error) {
	if c == nil {
		return load()
	}
	return c.cache.GetOrLoad(ctx, id, load)
}

// getOrLoadBatch resolves a page of shared channel rows with one database read
// for all misses. A zero-ID Channel is the negative-cache sentinel for an ID
// that disappeared between the owner-state query and shared-row hydration;
// channel_base invalidation removes both positive and negative entries.
func (c *ChannelRowCache) getOrLoadBatch(
	ctx context.Context,
	ids []int64,
	load func(context.Context, []int64) (map[int64]domain.Channel, error),
) (map[int64]domain.Channel, error) {
	if c == nil {
		return load(ctx, ids)
	}
	return c.cache.GetOrLoadBatch(
		ctx,
		ids,
		func(int64) (int64, bool) { return 0, true },
		func(ctx context.Context, missing []int64) (map[int64]domain.Channel, error) {
			loaded, err := load(ctx, missing)
			if err != nil {
				return nil, err
			}
			// GetOrLoadBatch requires an explicit value for every key so absent
			// rows do not immediately stampede the database again.
			for _, id := range missing {
				if _, ok := loaded[id]; !ok {
					loaded[id] = domain.Channel{}
				}
			}
			return loaded, nil
		},
	)
}

func (c *ChannelRowCache) put(ch domain.Channel) {
	if c == nil || ch.ID == 0 {
		return
	}
	c.cache.Store(ch.ID, ch)
}

// delete invalidates a single channel (called when the listener receives a change notification for that id).
func (c *ChannelRowCache) delete(id int64) {
	if c == nil || id == 0 {
		return
	}
	c.cache.Invalidate(id)
}

// flush clears the whole table (called by the listener on every (re)connect after establishing
// LISTEN, covering notifications lost in the disconnect window).
func (c *ChannelRowCache) flush() {
	if c == nil {
		return
	}
	c.cache.Flush()
}

// cloneChannelRow deep-copies the channel row's mutable fields (slices), so the cache and the
// caller do not alias-pollute each other.
func cloneChannelRow(ch domain.Channel) domain.Channel {
	if ch.PhotoStripped != nil {
		ch.PhotoStripped = append([]byte(nil), ch.PhotoStripped...)
	}
	if ch.ReactionPolicy.Emoticons != nil {
		ch.ReactionPolicy.Emoticons = append([]string(nil), ch.ReactionPolicy.Emoticons...)
	}
	if ch.ReactionPolicy.CustomEmojiIDs != nil {
		ch.ReactionPolicy.CustomEmojiIDs = append([]int64(nil), ch.ReactionPolicy.CustomEmojiIDs...)
	}
	ch.Wallpaper = domain.CloneWallpaperPtr(ch.Wallpaper)
	return ch
}