merged from gramsrv upstream

This commit is contained in:
onysd 2026-09-01 12:06:31 +03:00
parent 79c64ee916
commit 21a0856587
651 changed files with 54774 additions and 4590 deletions

View file

@ -2,19 +2,31 @@ package channels
import (
"context"
"errors"
"fmt"
"time"
"telesrv/internal/app/readmodel"
"telesrv/internal/domain"
"telesrv/internal/readmodelcache"
"telesrv/internal/store"
)
const (
defaultActiveChannelIDsReadModelTTL = 24 * time.Hour
activeChannelIDsReadModelMaxEntries = 8192
activeChannelIDsReadModelMaxEntries = 32768
activeChannelIDsNoVersionHash = -1
activeChannelIDsStableCutAttempts = 2
)
var errActiveChannelIDsGenerationChanged = errors.New("active channel IDs generation changed")
// ActiveChannelIDsReadModelMetrics records bounded shared-cache outcomes.
// User IDs and page selectors are deliberately excluded.
type ActiveChannelIDsReadModelMetrics interface {
ActiveChannelIDsCache(outcome string)
}
type activeChannelIDsCacheKey struct {
userID int64
afterChannelID int64
@ -27,13 +39,16 @@ type activeChannelIDsReadModelCache struct {
cache *readmodelcache.Cache[activeChannelIDsCacheKey, []int64]
}
func newActiveChannelIDsReadModelCache(ttl time.Duration) *activeChannelIDsReadModelCache {
func newActiveChannelIDsReadModelCache(maxEntries int, ttl time.Duration) *activeChannelIDsReadModelCache {
if maxEntries <= 0 {
maxEntries = activeChannelIDsReadModelMaxEntries
}
if ttl <= 0 {
ttl = defaultActiveChannelIDsReadModelTTL
}
return &activeChannelIDsReadModelCache{
cache: readmodelcache.New[activeChannelIDsCacheKey, []int64](readmodelcache.Config[activeChannelIDsCacheKey, []int64]{
MaxEntries: activeChannelIDsReadModelMaxEntries,
MaxEntries: maxEntries,
TTL: ttl,
Clone: cloneInt64s,
}),
@ -67,20 +82,114 @@ func (c *activeChannelIDsReadModelCache) invalidateUsers(userIDs ...int64) {
}
func (s *Service) cachedActiveChannelIDsForUser(ctx context.Context, userID, afterChannelID int64, limit int) ([]int64, error) {
if s.activeIDsCache == nil || s.versions == nil {
return s.channels.ListActiveChannelIDsForUser(ctx, userID, afterChannelID, limit)
if s.activeIDsShared == nil {
if s.activeIDsCache == nil || s.versions == nil {
return s.channels.ListActiveChannelIDsForUser(ctx, userID, afterChannelID, limit)
}
hash, err := s.activeChannelIDsGeneration(ctx, userID)
if err != nil {
return nil, err
}
key := activeChannelIDsCacheKey{userID: userID, afterChannelID: afterChannelID, limit: limit}
return s.activeIDsCache.getOrLoad(ctx, key, hash, func() ([]int64, error) {
return s.channels.ListActiveChannelIDsForUser(ctx, userID, afterChannelID, limit)
})
}
if s.activeIDsCache == nil || s.versions == nil || s.activeIDsLoader == nil {
return nil, errors.New("shared active channel IDs read model is incompletely configured")
}
key := activeChannelIDsCacheKey{userID: userID, afterChannelID: afterChannelID, limit: limit}
for attempt := 0; attempt < activeChannelIDsStableCutAttempts; attempt++ {
generation, err := s.activeChannelIDsGeneration(ctx, userID)
if err != nil {
return nil, err
}
channelIDs, err := s.activeIDsCache.getOrLoad(ctx, key, generation, func() ([]int64, error) {
return s.loadSharedActiveChannelIDsPage(ctx, key, generation)
})
if errors.Is(err, errActiveChannelIDsGenerationChanged) {
s.recordActiveChannelIDsCache("generation_retry")
continue
}
if err != nil {
return nil, err
}
currentGeneration, err := s.activeChannelIDsGeneration(ctx, userID)
if err != nil {
return nil, err
}
if currentGeneration != generation {
s.recordActiveChannelIDsCache("generation_retry")
s.activeIDsCache.invalidateUsers(userID)
continue
}
s.recordActiveChannelIDsCache("served")
return channelIDs, nil
}
return nil, errActiveChannelIDsGenerationChanged
}
func (s *Service) activeChannelIDsGeneration(ctx context.Context, userID int64) (int64, error) {
if s == nil || s.versions == nil {
return 0, errors.New("active channel IDs read model requires durable versions")
}
hash, ok, err := s.versions.ReadModelHash(ctx, readmodel.ModelChannelActiveIDs, userID, domain.PeerTypeUser, userID)
if err != nil {
return nil, err
return 0, err
}
if !ok || hash == 0 {
hash = activeChannelIDsNoVersionHash
return activeChannelIDsNoVersionHash, nil
}
return hash, nil
}
func (s *Service) loadSharedActiveChannelIDsPage(
ctx context.Context,
key activeChannelIDsCacheKey,
generation int64,
) ([]int64, error) {
sharedKey := store.ActiveChannelIDsPageKey{
UserID: key.userID, Generation: generation,
AfterChannelID: key.afterChannelID, Limit: key.limit,
}
channelIDs, found, err := s.activeIDsShared.GetActiveChannelIDsPage(ctx, sharedKey)
if err != nil {
s.recordActiveChannelIDsCache("read_error")
return nil, err
}
if found {
s.recordActiveChannelIDsCache("hit")
return channelIDs, nil
}
s.recordActiveChannelIDsCache("miss")
channelIDs, err = s.activeIDsLoader.ListActiveChannelIDsForUser(
ctx, key.userID, key.afterChannelID, key.limit,
)
if err != nil {
return nil, err
}
if generation == activeChannelIDsNoVersionHash && len(channelIDs) != 0 {
return nil, fmt.Errorf("active channel IDs generation missing for non-empty owner %d", key.userID)
}
currentGeneration, err := s.activeChannelIDsGeneration(ctx, key.userID)
if err != nil {
return nil, err
}
if currentGeneration != generation {
return nil, errActiveChannelIDsGenerationChanged
}
if err := s.activeIDsShared.PutActiveChannelIDsPage(ctx, sharedKey, channelIDs); err != nil {
s.recordActiveChannelIDsCache("write_error")
return nil, err
}
s.recordActiveChannelIDsCache("fill")
return channelIDs, nil
}
func (s *Service) recordActiveChannelIDsCache(outcome string) {
if s != nil && s.activeIDsMetrics != nil {
s.activeIDsMetrics.ActiveChannelIDsCache(outcome)
}
key := activeChannelIDsCacheKey{userID: userID, afterChannelID: afterChannelID, limit: limit}
return s.activeIDsCache.getOrLoad(ctx, key, hash, func() ([]int64, error) {
return s.channels.ListActiveChannelIDsForUser(ctx, userID, afterChannelID, limit)
})
}
func cloneInt64s(in []int64) []int64 {

View file

@ -0,0 +1,261 @@
package channels
import (
"context"
"errors"
"slices"
"sync"
"testing"
"telesrv/internal/domain"
"telesrv/internal/store"
"telesrv/internal/store/memory"
)
type fakeActiveChannelIDsPageCache struct {
mu sync.Mutex
values map[store.ActiveChannelIDsPageKey][]int64
getErr error
putErr error
gets int
puts int
}
func (f *fakeActiveChannelIDsPageCache) GetActiveChannelIDsPage(
_ context.Context,
key store.ActiveChannelIDsPageKey,
) ([]int64, bool, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.gets++
if f.getErr != nil {
return nil, false, f.getErr
}
value, found := f.values[key]
return append([]int64(nil), value...), found, nil
}
func (f *fakeActiveChannelIDsPageCache) PutActiveChannelIDsPage(
_ context.Context,
key store.ActiveChannelIDsPageKey,
value []int64,
) error {
f.mu.Lock()
defer f.mu.Unlock()
f.puts++
if f.putErr != nil {
return f.putErr
}
if f.values == nil {
f.values = make(map[store.ActiveChannelIDsPageKey][]int64)
}
f.values[key] = append([]int64(nil), value...)
return nil
}
type fakeActiveChannelIDsLoader struct {
mu sync.Mutex
values []int64
err error
calls int
onLoad func()
}
func (f *fakeActiveChannelIDsLoader) ListActiveChannelIDsForUser(
_ context.Context,
_, _ int64,
_ int,
) ([]int64, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.calls++
if f.onLoad != nil {
f.onLoad()
}
return append([]int64(nil), f.values...), f.err
}
type fakeActiveChannelIDsMetrics struct {
mu sync.Mutex
outcomes map[string]int
}
type mutableReadModelVersions struct {
mu sync.Mutex
hashes map[store.ReadModelKey]int64
}
func (m *mutableReadModelVersions) ReadModelHash(
_ context.Context,
model string,
ownerUserID int64,
peerType domain.PeerType,
peerID int64,
) (int64, bool, error) {
key := store.ReadModelKey{Model: model, OwnerUserID: ownerUserID, PeerType: peerType, PeerID: peerID}
m.mu.Lock()
defer m.mu.Unlock()
hash := m.hashes[key]
return hash, hash != 0, nil
}
func (m *mutableReadModelVersions) ReadModelHashes(
_ context.Context,
keys []store.ReadModelKey,
) (map[store.ReadModelKey]int64, error) {
m.mu.Lock()
defer m.mu.Unlock()
out := make(map[store.ReadModelKey]int64, len(keys))
for _, key := range keys {
out[key] = m.hashes[key]
}
return out, nil
}
func (m *mutableReadModelVersions) set(key store.ReadModelKey, hash int64) {
m.mu.Lock()
m.hashes[key] = hash
m.mu.Unlock()
}
func (f *fakeActiveChannelIDsMetrics) ActiveChannelIDsCache(outcome string) {
f.mu.Lock()
defer f.mu.Unlock()
if f.outcomes == nil {
f.outcomes = make(map[string]int)
}
f.outcomes[outcome]++
}
func TestActiveChannelIDsSharedPageSurvivesServiceRestart(t *testing.T) {
ctx := context.Background()
const ownerID int64 = 1001
key := store.ReadModelKey{Model: "channel_active_memberships", OwnerUserID: ownerID, PeerType: domain.PeerTypeUser, PeerID: ownerID}
versions := &fakeReadModelVersions{hashes: map[store.ReadModelKey]int64{key: 501}}
shared := &fakeActiveChannelIDsPageCache{}
firstLoader := &fakeActiveChannelIDsLoader{values: []int64{11, 12}}
firstMetrics := &fakeActiveChannelIDsMetrics{}
first := NewService(memory.NewChannelStore(),
WithReadModelVersions(versions),
WithActiveChannelIDsReadModel(shared, firstLoader, 32, 0, firstMetrics),
)
got, err := first.ActiveChannelIDsForUser(ctx, ownerID, 0, 1000)
if err != nil || !slices.Equal(got, []int64{11, 12}) {
t.Fatalf("first page = %v err=%v", got, err)
}
if firstLoader.calls != 1 || shared.puts != 1 || firstMetrics.outcomes["miss"] != 1 || firstMetrics.outcomes["fill"] != 1 {
t.Fatalf("first load calls=%d puts=%d metrics=%v", firstLoader.calls, shared.puts, firstMetrics.outcomes)
}
secondLoader := &fakeActiveChannelIDsLoader{err: errors.New("cold loader must not run")}
secondMetrics := &fakeActiveChannelIDsMetrics{}
second := NewService(memory.NewChannelStore(),
WithReadModelVersions(versions),
WithActiveChannelIDsReadModel(shared, secondLoader, 32, 0, secondMetrics),
)
got, err = second.ActiveChannelIDsForUser(ctx, ownerID, 0, 1000)
if err != nil || !slices.Equal(got, []int64{11, 12}) {
t.Fatalf("restart page = %v err=%v", got, err)
}
if secondLoader.calls != 0 || secondMetrics.outcomes["hit"] != 1 || secondMetrics.outcomes["served"] != 1 {
t.Fatalf("restart loader=%d metrics=%v", secondLoader.calls, secondMetrics.outcomes)
}
}
func TestActiveChannelIDsSharedPageFailsClosedOnRedisError(t *testing.T) {
const ownerID int64 = 1001
key := store.ReadModelKey{Model: "channel_active_memberships", OwnerUserID: ownerID, PeerType: domain.PeerTypeUser, PeerID: ownerID}
versions := &fakeReadModelVersions{hashes: map[store.ReadModelKey]int64{key: 601}}
shared := &fakeActiveChannelIDsPageCache{getErr: errors.New("redis down")}
loader := &fakeActiveChannelIDsLoader{values: []int64{11}}
service := NewService(memory.NewChannelStore(),
WithReadModelVersions(versions),
WithActiveChannelIDsReadModel(shared, loader, 32, 0, nil),
)
if _, err := service.ActiveChannelIDsForUser(context.Background(), ownerID, 0, 1000); err == nil {
t.Fatal("Redis error was silently bypassed")
}
if loader.calls != 0 {
t.Fatalf("cold loader calls = %d, want 0", loader.calls)
}
}
func TestActiveChannelIDsSharedPageRetriesGenerationChange(t *testing.T) {
ctx := context.Background()
const ownerID int64 = 1001
key := store.ReadModelKey{Model: "channel_active_memberships", OwnerUserID: ownerID, PeerType: domain.PeerTypeUser, PeerID: ownerID}
versions := &fakeReadModelVersions{hashes: map[store.ReadModelKey]int64{key: 701}}
shared := &fakeActiveChannelIDsPageCache{}
loader := &fakeActiveChannelIDsLoader{values: []int64{11}}
loader.onLoad = func() {
if loader.calls == 1 {
versions.hashes[key] = 702
loader.values = []int64{11, 12}
}
}
metrics := &fakeActiveChannelIDsMetrics{}
service := NewService(memory.NewChannelStore(),
WithReadModelVersions(versions),
WithActiveChannelIDsReadModel(shared, loader, 32, 0, metrics),
)
got, err := service.ActiveChannelIDsForUser(ctx, ownerID, 0, 1000)
if err != nil || !slices.Equal(got, []int64{11, 12}) {
t.Fatalf("page = %v err=%v", got, err)
}
if loader.calls != 2 || shared.puts != 1 || metrics.outcomes["generation_retry"] != 1 {
t.Fatalf("loader=%d puts=%d metrics=%v", loader.calls, shared.puts, metrics.outcomes)
}
oldKey := store.ActiveChannelIDsPageKey{UserID: ownerID, Generation: 701, AfterChannelID: 0, Limit: 1000}
if _, found := shared.values[oldKey]; found {
t.Fatal("generation-raced page was stored under old key")
}
}
func TestActiveChannelIDsSharedMissingGenerationOnlyCachesEmpty(t *testing.T) {
shared := &fakeActiveChannelIDsPageCache{}
loader := &fakeActiveChannelIDsLoader{values: []int64{11}}
service := NewService(memory.NewChannelStore(),
WithReadModelVersions(&fakeReadModelVersions{}),
WithActiveChannelIDsReadModel(shared, loader, 32, 0, nil),
)
if _, err := service.ActiveChannelIDsForUser(context.Background(), 1001, 0, 1000); err == nil {
t.Fatal("non-empty page without durable generation accepted")
}
if shared.puts != 0 {
t.Fatalf("shared puts = %d, want 0", shared.puts)
}
}
func TestActiveChannelIDsLocalWriteInvalidatesCachedGenerationBeforeNotify(t *testing.T) {
ctx := context.Background()
const ownerID int64 = 1001
versionKey := store.ReadModelKey{Model: "channel_active_memberships", OwnerUserID: ownerID, PeerType: domain.PeerTypeUser, PeerID: ownerID}
baseVersions := &mutableReadModelVersions{hashes: map[store.ReadModelKey]int64{versionKey: 801}}
cachedVersions := store.NewCachedReadModelVersionStore(baseVersions, 0, 32)
oldPageKey := store.ActiveChannelIDsPageKey{UserID: ownerID, Generation: 801, AfterChannelID: 0, Limit: 1000}
shared := &fakeActiveChannelIDsPageCache{values: map[store.ActiveChannelIDsPageKey][]int64{oldPageKey: {11}}}
loader := &fakeActiveChannelIDsLoader{values: []int64{11, 12}}
service := NewService(memory.NewChannelStore(),
WithReadModelVersions(cachedVersions),
WithActiveChannelIDsReadModel(shared, loader, 32, 0, nil),
)
first, err := service.ActiveChannelIDsForUser(ctx, ownerID, 0, 1000)
if err != nil || !slices.Equal(first, []int64{11}) {
t.Fatalf("first = %v err=%v", first, err)
}
baseVersions.set(versionKey, 802)
// Simulate the synchronous post-commit app hook before PostgreSQL NOTIFY is
// delivered to this process.
service.invalidateActiveChannelIDs(ownerID)
second, err := service.ActiveChannelIDsForUser(ctx, ownerID, 0, 1000)
if err != nil || !slices.Equal(second, []int64{11, 12}) {
t.Fatalf("after local invalidation = %v err=%v", second, err)
}
if loader.calls != 1 {
t.Fatalf("cold loader calls = %d, want 1 for new generation", loader.calls)
}
newPageKey := store.ActiveChannelIDsPageKey{UserID: ownerID, Generation: 802, AfterChannelID: 0, Limit: 1000}
if !slices.Equal(shared.values[newPageKey], []int64{11, 12}) {
t.Fatalf("new generation page = %v", shared.values[newPageKey])
}
}

View file

@ -20,6 +20,14 @@ type channelResolveReadModelCache struct {
cache *readmodelcache.Cache[channelViewCacheKey, domain.ChannelView]
}
// authoritativeResolveChannelCache marks a store whose ResolveChannel path is
// already guarded by exact channel/member invalidation and reconnect flushes.
// Wrapping that path in a second version-token cache adds no freshness boundary
// and turns every process-cold access check into a read_model_versions query.
type authoritativeResolveChannelCache interface {
AuthoritativeResolveChannelCache()
}
func newChannelResolveReadModelCache(ttl time.Duration) *channelResolveReadModelCache {
if ttl <= 0 {
ttl = defaultChannelResolveReadModelTTL
@ -41,6 +49,9 @@ func (c *channelResolveReadModelCache) getOrLoad(ctx context.Context, key channe
}
func (s *Service) cachedResolveChannel(ctx context.Context, userID, channelID int64) (domain.ChannelView, error) {
if _, ok := s.channels.(authoritativeResolveChannelCache); ok {
return s.channels.ResolveChannel(ctx, userID, channelID)
}
if s.resolveCache == nil || s.versions == nil {
return s.channels.ResolveChannel(ctx, userID, channelID)
}

View file

@ -4,8 +4,10 @@ import (
"context"
"errors"
"strings"
"time"
"unicode/utf8"
"telesrv/internal/app/readmodel"
"telesrv/internal/domain"
"telesrv/internal/store"
)
@ -22,6 +24,9 @@ type Service struct {
mediaCountCache *mediaCountReadModelCache
participantCache *participantsReadModelCache
activeIDsCache *activeChannelIDsReadModelCache
activeIDsShared store.ActiveChannelIDsPageCache
activeIDsLoader store.ActiveChannelIDsPageLoader
activeIDsMetrics ActiveChannelIDsReadModelMetrics
botMemberIDsCache *activeBotMemberIDsCache
// reserved blocks the self-service UpdateUsername (not AdminSetUsername)
// from claiming a config.ReservedUsernames entry -- see
@ -35,6 +40,15 @@ type SendPermissionChecker interface {
CanSendMessages(ctx context.Context, userID int64) error
}
// channelStatsStore is an optional capability kept out of the broad
// store.ChannelStore contract. It lets focused test stores stay small while
// both production backends expose the complete bounded stats read model.
type channelStatsStore interface {
GetChannelStats(ctx context.Context, req domain.ChannelStatsRequest) (domain.ChannelStats, error)
GetChannelMessageStats(ctx context.Context, req domain.ChannelMessageStatsRequest) (domain.ChannelMessageStats, error)
ListChannelMessagePublicForwards(ctx context.Context, req domain.ChannelMessagePublicForwardListRequest) (domain.ChannelMessagePublicForwardList, error)
}
// NewService creates a channel service.
func NewService(channels store.ChannelStore, opts ...Option) *Service {
s := &Service{
@ -43,7 +57,7 @@ func NewService(channels store.ChannelStore, opts ...Option) *Service {
resolveCache: newChannelResolveReadModelCache(defaultChannelResolveReadModelTTL),
mediaCountCache: newMediaCountReadModelCache(defaultMediaCountReadModelTTL),
participantCache: newParticipantsReadModelCache(defaultParticipantsReadModelTTL),
activeIDsCache: newActiveChannelIDsReadModelCache(defaultActiveChannelIDsReadModelTTL),
activeIDsCache: newActiveChannelIDsReadModelCache(0, defaultActiveChannelIDsReadModelTTL),
botMemberIDsCache: newActiveBotMemberIDsCache(),
}
for _, opt := range opts {
@ -66,6 +80,25 @@ func WithReadModelVersions(v store.ReadModelVersionStore) Option {
}
}
// WithActiveChannelIDsReadModel installs the production shared readiness page
// cache and its bounded authoritative cold loader. Supplying the shared cache
// without either durable versions or a loader is a configuration error at read
// time; the service never silently falls back to per-session PostgreSQL reads.
func WithActiveChannelIDsReadModel(
shared store.ActiveChannelIDsPageCache,
loader store.ActiveChannelIDsPageLoader,
maxEntries int,
ttl time.Duration,
metrics ActiveChannelIDsReadModelMetrics,
) Option {
return func(s *Service) {
s.activeIDsShared = shared
s.activeIDsLoader = loader
s.activeIDsMetrics = metrics
s.activeIDsCache = newActiveChannelIDsReadModelCache(maxEntries, ttl)
}
}
func WithSendPermissionChecker(c SendPermissionChecker) Option {
return func(s *Service) {
s.sendGate = c
@ -202,6 +235,51 @@ func (s *Service) CountChannelMediaCategories(ctx context.Context, userID, chann
return s.cachedChannelMediaCounts(ctx, userID, channelID)
}
// GetStats returns bounded aggregates derived from durable channel facts.
func (s *Service) GetStats(ctx context.Context, userID int64, req domain.ChannelStatsRequest) (domain.ChannelStats, error) {
if s == nil || s.channels == nil || userID == 0 || req.ChannelID == 0 || !req.Period.Valid() {
return domain.ChannelStats{}, domain.ErrChannelInvalid
}
provider, ok := s.channels.(channelStatsStore)
if !ok {
return domain.ChannelStats{}, domain.ErrChannelInvalid
}
req.ViewerUserID = userID
return provider.GetChannelStats(ctx, req)
}
// GetMessageStats returns view/reaction event buckets for one exact post.
func (s *Service) GetMessageStats(ctx context.Context, userID int64, req domain.ChannelMessageStatsRequest) (domain.ChannelMessageStats, error) {
if s == nil || s.channels == nil || userID == 0 || req.ChannelID == 0 || req.MessageID <= 0 ||
req.MessageID > domain.MaxMessageBoxID || !req.Period.Valid() {
return domain.ChannelMessageStats{}, domain.ErrMessageIDInvalid
}
provider, ok := s.channels.(channelStatsStore)
if !ok {
return domain.ChannelMessageStats{}, domain.ErrChannelInvalid
}
req.ViewerUserID = userID
return provider.GetChannelMessageStats(ctx, req)
}
// ListMessagePublicForwards returns only public destination posts with a
// validated seek cursor; private forwards never cross this boundary.
func (s *Service) ListMessagePublicForwards(ctx context.Context, userID int64, req domain.ChannelMessagePublicForwardListRequest) (domain.ChannelMessagePublicForwardList, error) {
if s == nil || s.channels == nil || userID == 0 || req.ChannelID == 0 || req.MessageID <= 0 ||
req.MessageID > domain.MaxMessageBoxID || req.Limit <= 0 || req.Limit > domain.MaxChannelMessagePublicForwards {
return domain.ChannelMessagePublicForwardList{}, domain.ErrChannelInvalid
}
if _, err := domain.ParseChannelMessagePublicForwardCursor(req.Offset); err != nil {
return domain.ChannelMessagePublicForwardList{}, err
}
provider, ok := s.channels.(channelStatsStore)
if !ok {
return domain.ChannelMessagePublicForwardList{}, domain.ErrChannelInvalid
}
req.ViewerUserID = userID
return provider.ListChannelMessagePublicForwards(ctx, req)
}
// GetChannels returns channel data personalized for userID, ordered by the first occurrence in channelIDs.
func (s *Service) GetChannels(ctx context.Context, userID int64, channelIDs []int64) ([]domain.ChannelView, error) {
if s == nil || s.channels == nil || userID == 0 {
@ -584,7 +662,7 @@ func (s *Service) AdminSetEmojiStatus(ctx context.Context, channelID int64, stat
// AdminSetPhoto force-sets a channel's avatar through the admin path (no
// permission checks, no "changed photo" service message).
func (s *Service) AdminSetPhoto(ctx context.Context, channelID int64, photo domain.Photo) (domain.Channel, error) {
if s == nil || s.channels == nil || channelID == 0 {
if s == nil || s.channels == nil || channelID == 0 || photo.ID == 0 {
return domain.Channel{}, domain.ErrChannelInvalid
}
return s.channels.SetChannelPhotoAdmin(ctx, channelID, photo)
@ -1968,7 +2046,15 @@ func (s *Service) SendMonoforumMessage(ctx context.Context, req domain.SendMonof
if err := s.ensureCanSend(ctx, req.SenderUserID); err != nil {
return domain.SendChannelMessageResult{}, err
}
return s.channels.SendMonoforumMessage(ctx, req)
result, err := s.channels.SendMonoforumMessage(ctx, req)
if err != nil {
return result, err
}
// The saved-peer owner gains (or refreshes) monoforum readiness visibility.
// Evict locally at commit return; migration 20260901000022 advances the durable token
// and NOTIFY handles every other process.
s.invalidateActiveChannelIDs(req.SavedPeer.ID)
return result, nil
}
// ListMonoforumHistory 拉取某订阅者在频道私信(monoforum)内的历史。
@ -2375,7 +2461,20 @@ func activeMembershipUserIDsFromMembers(primary int64, members []domain.ChannelM
}
func (s *Service) invalidateActiveChannelIDs(userIDs ...int64) {
if s == nil || s.activeIDsCache == nil {
if s == nil {
return
}
if versionCache, ok := s.versions.(store.ReadModelVersionCache); ok {
for _, userID := range uniqueNonZero(userIDs) {
versionCache.InvalidateReadModel(store.ReadModelKey{
Model: readmodel.ModelChannelActiveIDs,
OwnerUserID: userID,
PeerType: domain.PeerTypeUser,
PeerID: userID,
})
}
}
if s.activeIDsCache == nil {
return
}
s.activeIDsCache.invalidateUsers(userIDs...)

View file

@ -155,6 +155,12 @@ type countingChannelStore struct {
resolveStartOnce sync.Once
}
type authoritativeCountingChannelStore struct {
*countingChannelStore
}
func (*authoritativeCountingChannelStore) AuthoritativeResolveChannelCache() {}
func (s *countingChannelStore) GetChannel(ctx context.Context, viewerUserID, channelID int64) (domain.ChannelView, error) {
s.getChannelCalls++
return s.ChannelStore.GetChannel(ctx, viewerUserID, channelID)
@ -198,6 +204,20 @@ type fakeReadModelVersions struct {
hashes map[store.ReadModelKey]int64
}
type countingReadModelVersions struct {
calls int
}
func (v *countingReadModelVersions) ReadModelHash(context.Context, string, int64, domain.PeerType, int64) (int64, bool, error) {
v.calls++
return 0, false, nil
}
func (v *countingReadModelVersions) ReadModelHashes(context.Context, []store.ReadModelKey) (map[store.ReadModelKey]int64, error) {
v.calls++
return map[store.ReadModelKey]int64{}, nil
}
func (f *fakeReadModelVersions) ReadModelHash(_ context.Context, model string, ownerUserID int64, peerType domain.PeerType, peerID int64) (int64, bool, error) {
hash := f.hashes[store.ReadModelKey{Model: model, OwnerUserID: ownerUserID, PeerType: peerType, PeerID: peerID}]
return hash, hash != 0, nil
@ -331,6 +351,39 @@ func TestResolveChannelCachesAccessViewByCompositeReadModelHash(t *testing.T) {
}
}
func TestResolveChannelDelegatesToAuthoritativeStoreCacheWithoutVersionRead(t *testing.T) {
ctx := context.Background()
const ownerID int64 = 1001
base := &countingChannelStore{ChannelStore: memory.NewChannelStore()}
created, err := base.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: ownerID, Title: "Store-owned Resolve", Megagroup: true, Date: 1700004105,
})
if err != nil {
t.Fatalf("CreateChannel: %v", err)
}
versions := &countingReadModelVersions{}
service := NewService(
&authoritativeCountingChannelStore{countingChannelStore: base},
WithReadModelVersions(versions),
)
for range 2 {
view, resolveErr := service.ResolveChannel(ctx, ownerID, created.Channel.ID)
if resolveErr != nil {
t.Fatalf("ResolveChannel: %v", resolveErr)
}
if view.Channel.ID != created.Channel.ID || view.Self.UserID != ownerID {
t.Fatalf("resolve view = %+v", view)
}
}
if base.resolveChannelCalls != 2 {
t.Fatalf("authoritative store calls = %d, want 2", base.resolveChannelCalls)
}
if versions.calls != 0 {
t.Fatalf("read-model version calls = %d, want 0", versions.calls)
}
}
func TestActiveChannelIDsForUserCachesPageByReadModelHash(t *testing.T) {
ctx := context.Background()
const ownerID int64 = 1001
@ -872,8 +925,8 @@ func TestCreateChatCreatesMegagroupWithChannelPts(t *testing.T) {
if !created.Channel.Megagroup || created.Channel.Broadcast {
t.Fatalf("channel flags = megagroup:%v broadcast:%v, want megagroup only", created.Channel.Megagroup, created.Channel.Broadcast)
}
if created.Channel.Pts != 1 || created.Message.ID != 1 || created.Event.PtsCount != 1 {
t.Fatalf("created pts/message/event = %+v/%+v/%+v, want initial pts=1 message id=1", created.Channel, created.Message, created.Event)
if created.Channel.Pts != domain.FirstChannelEventPts || created.Message.ID != 1 || created.Event.Pts != domain.FirstChannelEventPts || created.Event.PtsCount != 1 {
t.Fatalf("created pts/message/event = %+v/%+v/%+v, want initial event pts=2 message id=1", created.Channel, created.Message, created.Event)
}
if created.Message.Action == nil || created.Message.Action.Type != domain.ChannelActionCreate {
t.Fatalf("create service action = %+v, want channel create", created.Message.Action)
@ -889,8 +942,8 @@ func TestCreateChatCreatesMegagroupWithChannelPts(t *testing.T) {
if err != nil {
t.Fatalf("SendMessage: %v", err)
}
if sent.Message.ID != 2 || sent.Message.Pts != 2 || sent.Event.Pts != 2 || sent.Event.PtsCount != 1 {
t.Fatalf("sent = %+v event=%+v, want message id/pts=2", sent.Message, sent.Event)
if sent.Message.ID != 2 || sent.Message.Pts != 3 || sent.Event.Pts != 3 || sent.Event.PtsCount != 1 {
t.Fatalf("sent = %+v event=%+v, want message id=2 pts=3", sent.Message, sent.Event)
}
if sent.Message.ViaBotID != 1003 || sent.Event.Message.ViaBotID != 1003 {
t.Fatalf("sent via_bot_id = msg %d event %d, want 1003", sent.Message.ViaBotID, sent.Event.Message.ViaBotID)
@ -924,12 +977,12 @@ func TestCreateChatCreatesMegagroupWithChannelPts(t *testing.T) {
t.Fatalf("history via_bot_id = %d, want 1003", history.Messages[0].ViaBotID)
}
diff, err := service.GetDifference(ctx, 1002, domain.ChannelDifferenceRequest{ChannelID: created.Channel.ID, Pts: 1, Limit: 10})
diff, err := service.GetDifference(ctx, 1002, domain.ChannelDifferenceRequest{ChannelID: created.Channel.ID, Pts: created.Event.Pts, Limit: 10})
if err != nil {
t.Fatalf("GetDifference: %v", err)
}
if !diff.Final || diff.Pts != 2 || len(diff.NewMessages) != 1 || diff.NewMessages[0].Body != "hello" {
t.Fatalf("diff = %+v, want single new channel message at pts=2", diff)
if !diff.Final || diff.Pts != 3 || len(diff.NewMessages) != 1 || diff.NewMessages[0].Body != "hello" {
t.Fatalf("diff = %+v, want single new channel message at pts=3", diff)
}
if diff.NewMessages[0].ViaBotID != 1003 {
t.Fatalf("diff via_bot_id = %d, want 1003", diff.NewMessages[0].ViaBotID)
@ -2116,8 +2169,8 @@ func TestChannelEditDeleteAndLocalClearUseChannelPts(t *testing.T) {
if err != nil {
t.Fatalf("EditMessage: %v", err)
}
if edited.Event.Type != domain.ChannelUpdateEditMessage || edited.Event.Pts != 4 || edited.Event.PtsCount != 1 {
t.Fatalf("edit event = %+v, want channel edit pts=4 count=1", edited.Event)
if edited.Event.Type != domain.ChannelUpdateEditMessage || edited.Event.Pts != 5 || edited.Event.PtsCount != 1 {
t.Fatalf("edit event = %+v, want channel edit pts=5 count=1", edited.Event)
}
duplicate, err := service.SendMessage(ctx, 1002, domain.SendChannelMessageRequest{ChannelID: created.Channel.ID, RandomID: 2, Message: "two", Date: 13})
if err != nil {
@ -2135,14 +2188,14 @@ func TestChannelEditDeleteAndLocalClearUseChannelPts(t *testing.T) {
if err != nil {
t.Fatalf("DeleteMessages: %v", err)
}
if deleted.Event.Type != domain.ChannelUpdateDeleteMessages || deleted.Event.Pts != 6 || deleted.Event.PtsCount != 2 {
if deleted.Event.Type != domain.ChannelUpdateDeleteMessages || deleted.Event.Pts != 7 || deleted.Event.PtsCount != 2 {
t.Fatalf("delete event = %+v, want pts advanced by deleted id count", deleted.Event)
}
diff, err := service.GetDifference(ctx, 1002, domain.ChannelDifferenceRequest{ChannelID: created.Channel.ID, Pts: 3, Limit: 10})
diff, err := service.GetDifference(ctx, 1002, domain.ChannelDifferenceRequest{ChannelID: created.Channel.ID, Pts: second.Event.Pts, Limit: 10})
if err != nil {
t.Fatalf("GetDifference: %v", err)
}
if len(diff.OtherUpdates) != 2 || diff.OtherUpdates[1].Type != domain.ChannelUpdateDeleteMessages || diff.Pts != 6 {
if len(diff.OtherUpdates) != 2 || diff.OtherUpdates[1].Type != domain.ChannelUpdateDeleteMessages || diff.Pts != 7 {
t.Fatalf("diff after edit/delete = %+v, want edit then delete through channel pts", diff)
}