merged from gramsrv upstream
This commit is contained in:
parent
79c64ee916
commit
21a0856587
651 changed files with 54774 additions and 4590 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
261
internal/app/channels/active_ids_shared_test.go
Normal file
261
internal/app/channels/active_ids_shared_test.go
Normal 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])
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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...)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue