[MM-67231] Etag fixes for autotranslations (#35196)
Some checks are pending
API / build (push) Waiting to run
Server CI / Compute Go Version (push) Waiting to run
Server CI / Check mocks (push) Blocked by required conditions
Server CI / Check go mod tidy (push) Blocked by required conditions
Server CI / check-style (push) Blocked by required conditions
Server CI / Check serialization methods for hot structs (push) Blocked by required conditions
Server CI / Vet API (push) Blocked by required conditions
Server CI / Check migration files (push) Blocked by required conditions
Server CI / Generate email templates (push) Blocked by required conditions
Server CI / Check store layers (push) Blocked by required conditions
Server CI / Check mmctl docs (push) Blocked by required conditions
Server CI / Postgres with binary parameters (push) Blocked by required conditions
Server CI / Postgres (push) Blocked by required conditions
Server CI / Postgres (FIPS) (push) Blocked by required conditions
Server CI / Generate Test Coverage (push) Blocked by required conditions
Server CI / Run mmctl tests (push) Blocked by required conditions
Server CI / Run mmctl tests (FIPS) (push) Blocked by required conditions
Server CI / Build mattermost server app (push) Blocked by required conditions
Web App CI / check-lint (push) Waiting to run
Web App CI / check-i18n (push) Blocked by required conditions
Web App CI / check-types (push) Blocked by required conditions
Web App CI / test (platform) (push) Blocked by required conditions
Web App CI / test (mattermost-redux) (push) Blocked by required conditions
Web App CI / test (channels shard 1/4) (push) Blocked by required conditions
Web App CI / test (channels shard 2/4) (push) Blocked by required conditions
Web App CI / test (channels shard 3/4) (push) Blocked by required conditions
Web App CI / test (channels shard 4/4) (push) Blocked by required conditions
Web App CI / upload-coverage (push) Blocked by required conditions
Web App CI / build (push) Blocked by required conditions

This commit is contained in:
Ben Cooke 2026-02-09 18:32:26 -05:00 committed by GitHub
parent 74b5fb066c
commit 76b3528c2b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 334 additions and 202 deletions

View file

@ -1207,7 +1207,17 @@ func (a *App) GetPosts(rctx request.CTX, channelID string, offset int, limit int
}
func (a *App) GetPostsEtag(channelID string, collapsedThreads bool) string {
return a.Srv().Store().Post().GetEtag(channelID, true, collapsedThreads)
if a.AutoTranslation() == nil || !a.AutoTranslation().IsFeatureAvailable() {
return a.Srv().Store().Post().GetEtag(channelID, true, collapsedThreads, false)
}
channelEnabled, err := a.AutoTranslation().IsChannelEnabled(channelID)
if err != nil || !channelEnabled {
return a.Srv().Store().Post().GetEtag(channelID, true, collapsedThreads, false)
}
// Channel has auto-translation enabled - include translation etag
return a.Srv().Store().Post().GetEtag(channelID, true, collapsedThreads, true)
}
func (a *App) GetPostsSince(rctx request.CTX, options model.GetPostsSinceOptions) (*model.PostList, *model.AppError) {

View file

@ -301,3 +301,9 @@ channels/db/migrations/postgres/000151_add_autotranslationdisabled_to_channelmem
channels/db/migrations/postgres/000151_add_autotranslationdisabled_to_channelmembers.up.sql
channels/db/migrations/postgres/000152_translations_primary_key_change.down.sql
channels/db/migrations/postgres/000152_translations_primary_key_change.up.sql
channels/db/migrations/postgres/000153_add_translation_channel_id.down.sql
channels/db/migrations/postgres/000153_add_translation_channel_id.up.sql
channels/db/migrations/postgres/000154_drop_translation_updateat_index.down.sql
channels/db/migrations/postgres/000154_drop_translation_updateat_index.up.sql
channels/db/migrations/postgres/000155_create_translation_channel_updateat_index.down.sql
channels/db/migrations/postgres/000155_create_translation_channel_updateat_index.up.sql

View file

@ -0,0 +1 @@
ALTER TABLE translations DROP COLUMN IF EXISTS channelid;

View file

@ -0,0 +1 @@
ALTER TABLE translations ADD COLUMN IF NOT EXISTS channelid varchar(26);

View file

@ -0,0 +1,3 @@
-- morph:nontransactional
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_translations_updateat
ON translations (updateAt DESC);

View file

@ -0,0 +1,2 @@
-- morph:nontransactional
DROP INDEX CONCURRENTLY IF EXISTS idx_translations_updateat;

View file

@ -0,0 +1,2 @@
-- morph:nontransactional
DROP INDEX CONCURRENTLY IF EXISTS idx_translations_channel_updateat;

View file

@ -0,0 +1,3 @@
-- morph:nontransactional
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_translations_channel_updateat
ON translations(channelid, objectType, updateAt DESC, dstlang);

View file

@ -25,7 +25,11 @@ func userLanguageKey(userID, channelID string) string {
return fmt.Sprintf("lang:%s:%s", userID, channelID)
}
// Cluster invalidation handler
func postTranslationEtagKey(channelID string) string {
return fmt.Sprintf("etag:%s", channelID)
}
// Cluster invalidation handler for user auto-translation cache
func (s *LocalCacheAutoTranslationStore) handleClusterInvalidateUserAutoTranslation(msg *model.ClusterMessage) {
if bytes.Equal(msg.Data, clearCacheMessageData) {
s.rootStore.userAutoTranslationCache.Purge()
@ -34,12 +38,23 @@ func (s *LocalCacheAutoTranslationStore) handleClusterInvalidateUserAutoTranslat
}
}
// Cluster invalidation handler for post translation etag cache
func (s *LocalCacheAutoTranslationStore) handleClusterInvalidatePostTranslationEtag(msg *model.ClusterMessage) {
if bytes.Equal(msg.Data, clearCacheMessageData) {
s.rootStore.postTranslationEtagCache.Purge()
} else {
s.rootStore.postTranslationEtagCache.Remove(string(msg.Data))
}
}
// ClearCaches purges all auto-translation caches
func (s LocalCacheAutoTranslationStore) ClearCaches() {
s.rootStore.doClearCacheCluster(s.rootStore.userAutoTranslationCache)
s.rootStore.doClearCacheCluster(s.rootStore.postTranslationEtagCache)
if s.rootStore.metrics != nil {
s.rootStore.metrics.IncrementMemCacheInvalidationCounter(s.rootStore.userAutoTranslationCache.Name())
s.rootStore.metrics.IncrementMemCacheInvalidationCounter(s.rootStore.postTranslationEtagCache.Name())
}
}
@ -133,3 +148,48 @@ func (s LocalCacheAutoTranslationStore) InvalidateUserLocaleCache(userID string)
s.rootStore.metrics.IncrementMemCacheInvalidationCounter(s.rootStore.userAutoTranslationCache.Name())
}
}
// GetLatestPostUpdateAtForChannel returns the most recent updateAt timestamp for post translations
// in the given channel (across all locales, with caching)
func (s LocalCacheAutoTranslationStore) GetLatestPostUpdateAtForChannel(channelID string) (int64, error) {
key := postTranslationEtagKey(channelID)
var updateAt int64
if err := s.rootStore.doStandardReadCache(s.rootStore.postTranslationEtagCache, key, &updateAt); err == nil {
return updateAt, nil
}
updateAt, err := s.AutoTranslationStore.GetLatestPostUpdateAtForChannel(channelID)
if err != nil {
return 0, err
}
s.rootStore.doStandardAddToCache(s.rootStore.postTranslationEtagCache, key, updateAt)
return updateAt, nil
}
// InvalidatePostTranslationEtag invalidates the cached post translation etag for a channel
// This should be called after saving a new post translation
func (s LocalCacheAutoTranslationStore) InvalidatePostTranslationEtag(channelID string) {
key := postTranslationEtagKey(channelID)
s.rootStore.doInvalidateCacheCluster(s.rootStore.postTranslationEtagCache, key, nil)
if s.rootStore.metrics != nil {
s.rootStore.metrics.IncrementMemCacheInvalidationCounter(s.rootStore.postTranslationEtagCache.Name())
}
}
// Save wraps the underlying Save and invalidates the post translation etag cache for post translations
func (s LocalCacheAutoTranslationStore) Save(translation *model.Translation) error {
err := s.AutoTranslationStore.Save(translation)
if err != nil {
return err
}
// Invalidate post translation etag cache only for post translations
if translation.ChannelID != "" && translation.ObjectType == model.TranslationObjectTypePost {
s.InvalidatePostTranslationEtag(translation.ChannelID)
}
return nil
}

View file

@ -73,6 +73,8 @@ const (
// Auto-translation caches
UserAutoTranslationCacheSize = 50000 // User+channel combos
UserAutoTranslationCacheSec = 15 * 60
PostTranslationEtagCacheSize = 25000 // Channel etags for post translations
PostTranslationEtagCacheSec = 15 * 60
ContentFlaggingCacheSize = 100
@ -138,6 +140,7 @@ type LocalCacheStore struct {
autotranslation LocalCacheAutoTranslationStore
userAutoTranslationCache cache.Cache
postTranslationEtagCache cache.Cache
contentFlagging LocalCacheContentFlaggingStore
contentFlaggingCache cache.Cache
@ -400,6 +403,14 @@ func NewLocalCacheLayer(baseStore store.Store, metrics einterfaces.MetricsInterf
}); err != nil {
return
}
if localCacheStore.postTranslationEtagCache, err = cacheProvider.NewCache(&cache.CacheOptions{
Size: PostTranslationEtagCacheSize,
Name: "PostTranslationEtag",
DefaultExpiry: PostTranslationEtagCacheSec * time.Second,
InvalidateClusterEvent: model.ClusterEventInvalidateCacheForPostTranslationEtag,
}); err != nil {
return
}
localCacheStore.autotranslation = LocalCacheAutoTranslationStore{AutoTranslationStore: baseStore.AutoTranslation(), rootStore: &localCacheStore}
if localCacheStore.contentFlaggingCache, err = cacheProvider.NewCache(&cache.CacheOptions{
Size: ContentFlaggingCacheSize,
@ -470,6 +481,7 @@ func NewLocalCacheLayer(baseStore store.Store, metrics einterfaces.MetricsInterf
cluster.RegisterClusterMessageHandler(model.ClusterEventInvalidateCacheForAllProfiles, localCacheStore.user.handleClusterInvalidateAllProfiles)
cluster.RegisterClusterMessageHandler(model.ClusterEventInvalidateCacheForTeams, localCacheStore.team.handleClusterInvalidateTeam)
cluster.RegisterClusterMessageHandler(model.ClusterEventInvalidateCacheForUserAutoTranslation, localCacheStore.autotranslation.handleClusterInvalidateUserAutoTranslation)
cluster.RegisterClusterMessageHandler(model.ClusterEventInvalidateCacheForPostTranslationEtag, localCacheStore.autotranslation.handleClusterInvalidatePostTranslationEtag)
cluster.RegisterClusterMessageHandler(model.ClusterEventInvalidateCacheForContentFlagging, localCacheStore.contentFlagging.handleClusterInvalidateContentFlagging)
cluster.RegisterClusterMessageHandler(model.ClusterEventInvalidateCacheForReadReceipts, localCacheStore.readReceipt.handleClusterInvalidateReadReceipts)
cluster.RegisterClusterMessageHandler(model.ClusterEventInvalidateCacheForTemporaryPosts, localCacheStore.temporaryPost.handleClusterInvalidateTemporaryPosts)
@ -671,6 +683,7 @@ func (s *LocalCacheStore) Invalidate() {
s.doClearCacheCluster(s.teamAllTeamIdsForUserCache)
s.doClearCacheCluster(s.rolePermissionsCache)
s.doClearCacheCluster(s.userAutoTranslationCache)
s.doClearCacheCluster(s.postTranslationEtagCache)
s.doClearCacheCluster(s.readReceiptCache)
s.doClearCacheCluster(s.readReceiptPostReadersCache)
s.doClearCacheCluster(s.temporaryPostCache)

View file

@ -42,6 +42,8 @@ func getMockStore(t *testing.T) *mocks.Store {
mockStore.On("Reaction").Return(&mockReactionsStore)
mockAutoTranslationStore := mocks.AutoTranslationStore{}
// GetLatestPostUpdateAtForChannel now takes only channelID (no locale) since caching is per-channel
mockAutoTranslationStore.On("GetLatestPostUpdateAtForChannel", "channelId").Return(int64(5000), nil)
mockStore.On("AutoTranslation").Return(&mockAutoTranslationStore)
fakeRole := model.Role{Id: "123", Name: "role-name"}
@ -140,8 +142,10 @@ func getMockStore(t *testing.T) *mocks.Store {
mockPostStoreEtagResult := fmt.Sprintf("%v.%v", model.CurrentVersion, 1)
mockPostStore.On("ClearCaches")
mockPostStore.On("InvalidateLastPostTimeCache", "channelId")
mockPostStore.On("GetEtag", "channelId", true, false).Return(mockPostStoreEtagResult)
mockPostStore.On("GetEtag", "channelId", false, false).Return(mockPostStoreEtagResult)
mockPostStore.On("GetEtag", "channelId", true, false, false).Return(mockPostStoreEtagResult)
mockPostStore.On("GetEtag", "channelId", false, false, false).Return(mockPostStoreEtagResult)
mockPostStore.On("GetEtag", "channelId", true, false, true).Return(mockPostStoreEtagResult)
mockPostStore.On("GetEtag", "channelId", false, false, true).Return(mockPostStoreEtagResult)
mockPostStore.On("GetPostsSince", mock.AnythingOfType("*request.Context"), mockPostStoreOptions, true, map[string]bool{}).Return(model.NewPostList(), nil)
mockPostStore.On("GetPostsSince", mock.AnythingOfType("*request.Context"), mockPostStoreOptions, false, map[string]bool{}).Return(model.NewPostList(), nil)
mockStore.On("Post").Return(&mockPostStore)

View file

@ -71,23 +71,35 @@ func (s LocalCachePostStore) InvalidateLastPostTimeCache(channelId string) {
}
}
func (s LocalCachePostStore) GetEtag(channelId string, allowFromCache, collapsedThreads bool) string {
func (s LocalCachePostStore) GetEtag(channelId string, allowFromCache, collapsedThreads bool, includeTranslations bool) string {
var baseEtag string
if allowFromCache {
var lastTime int64
if err := s.rootStore.doStandardReadCache(s.rootStore.lastPostTimeCache, channelId, &lastTime); err == nil {
return fmt.Sprintf("%v.%v", model.CurrentVersion, lastTime)
baseEtag = fmt.Sprintf("%v.%v", model.CurrentVersion, lastTime)
}
}
result := s.PostStore.GetEtag(channelId, allowFromCache, collapsedThreads)
if baseEtag == "" {
baseEtag = s.PostStore.GetEtag(channelId, allowFromCache, collapsedThreads, includeTranslations)
splittedResult := strings.Split(result, ".")
splittedResult := strings.Split(baseEtag, ".")
lastTime, _ := strconv.ParseInt((splittedResult[len(splittedResult)-1]), 10, 64)
lastTime, _ := strconv.ParseInt((splittedResult[len(splittedResult)-1]), 10, 64)
s.rootStore.doStandardAddToCache(s.rootStore.lastPostTimeCache, channelId, lastTime)
s.rootStore.doStandardAddToCache(s.rootStore.lastPostTimeCache, channelId, lastTime)
}
return result
// If translations should be included, append translation time
if includeTranslations {
translationTime, err := s.rootStore.AutoTranslation().GetLatestPostUpdateAtForChannel(channelId)
if err == nil && translationTime > 0 {
return fmt.Sprintf("%s_%d", baseEtag, translationTime)
}
}
return baseEtag
}
func (s LocalCachePostStore) GetPostsSince(rctx request.CTX, options model.GetPostsSinceOptions, allowFromCache bool, sanitizeOptions map[string]bool) (*model.PostList, error) {

View file

@ -41,11 +41,11 @@ func TestPostStoreLastPostTimeCache(t *testing.T) {
expectedResult := fmt.Sprintf("%v.%v", model.CurrentVersion, fakeLastTime)
etag := cachedStore.Post().GetEtag(channelId, true, false)
etag := cachedStore.Post().GetEtag(channelId, true, false, false)
assert.Equal(t, etag, expectedResult)
mockStore.Post().(*mocks.PostStore).AssertNumberOfCalls(t, "GetEtag", 1)
etag = cachedStore.Post().GetEtag(channelId, true, false)
etag = cachedStore.Post().GetEtag(channelId, true, false, false)
assert.Equal(t, etag, expectedResult)
mockStore.Post().(*mocks.PostStore).AssertNumberOfCalls(t, "GetEtag", 1)
})
@ -56,9 +56,9 @@ func TestPostStoreLastPostTimeCache(t *testing.T) {
cachedStore, err := NewLocalCacheLayer(mockStore, nil, nil, mockCacheProvider, logger)
require.NoError(t, err)
cachedStore.Post().GetEtag(channelId, true, false)
cachedStore.Post().GetEtag(channelId, true, false, false)
mockStore.Post().(*mocks.PostStore).AssertNumberOfCalls(t, "GetEtag", 1)
cachedStore.Post().GetEtag(channelId, false, false)
cachedStore.Post().GetEtag(channelId, false, false, false)
mockStore.Post().(*mocks.PostStore).AssertNumberOfCalls(t, "GetEtag", 2)
})
@ -68,10 +68,10 @@ func TestPostStoreLastPostTimeCache(t *testing.T) {
cachedStore, err := NewLocalCacheLayer(mockStore, nil, nil, mockCacheProvider, logger)
require.NoError(t, err)
cachedStore.Post().GetEtag(channelId, true, false)
cachedStore.Post().GetEtag(channelId, true, false, false)
mockStore.Post().(*mocks.PostStore).AssertNumberOfCalls(t, "GetEtag", 1)
cachedStore.Post().InvalidateLastPostTimeCache(channelId)
cachedStore.Post().GetEtag(channelId, true, false)
cachedStore.Post().GetEtag(channelId, true, false, false)
mockStore.Post().(*mocks.PostStore).AssertNumberOfCalls(t, "GetEtag", 2)
})
@ -81,10 +81,10 @@ func TestPostStoreLastPostTimeCache(t *testing.T) {
cachedStore, err := NewLocalCacheLayer(mockStore, nil, nil, mockCacheProvider, logger)
require.NoError(t, err)
cachedStore.Post().GetEtag(channelId, true, false)
cachedStore.Post().GetEtag(channelId, true, false, false)
mockStore.Post().(*mocks.PostStore).AssertNumberOfCalls(t, "GetEtag", 1)
cachedStore.Post().ClearCaches()
cachedStore.Post().GetEtag(channelId, true, false)
cachedStore.Post().GetEtag(channelId, true, false, false)
mockStore.Post().(*mocks.PostStore).AssertNumberOfCalls(t, "GetEtag", 2)
})
@ -144,6 +144,60 @@ func TestPostStoreLastPostTimeCache(t *testing.T) {
cachedStore.Post().GetPostsSince(rctx, fakeOptions, true, map[string]bool{})
mockStore.Post().(*mocks.PostStore).AssertNumberOfCalls(t, "GetPostsSince", 2)
})
t.Run("GetEtag: with includeTranslations appends translation time", func(t *testing.T) {
mockStore := getMockStore(t)
mockCacheProvider := getMockCacheProvider()
cachedStore, err := NewLocalCacheLayer(mockStore, nil, nil, mockCacheProvider, logger)
require.NoError(t, err)
// Expected: "{version}.{lastTime}.{translationTime}"
expectedResult := fmt.Sprintf("%v.%v_%v", model.CurrentVersion, fakeLastTime, 5000)
etag := cachedStore.Post().GetEtag(channelId, true, false, true)
assert.Equal(t, expectedResult, etag)
mockStore.Post().(*mocks.PostStore).AssertNumberOfCalls(t, "GetEtag", 1)
mockStore.AutoTranslation().(*mocks.AutoTranslationStore).AssertNumberOfCalls(t, "GetLatestPostUpdateAtForChannel", 1)
})
t.Run("GetEtag: translation etag is cached per-channel", func(t *testing.T) {
mockStore := getMockStore(t)
mockCacheProvider := getMockCacheProvider()
cachedStore, err := NewLocalCacheLayer(mockStore, nil, nil, mockCacheProvider, logger)
require.NoError(t, err)
// First call with includeTranslations=true - should call GetLatestPostUpdateAtForChannel
expected := fmt.Sprintf("%v.%v_%v", model.CurrentVersion, fakeLastTime, 5000)
etag := cachedStore.Post().GetEtag(channelId, true, false, true)
assert.Equal(t, expected, etag)
mockStore.AutoTranslation().(*mocks.AutoTranslationStore).AssertNumberOfCalls(t, "GetLatestPostUpdateAtForChannel", 1)
// Second call - should use cached translation time
etag = cachedStore.Post().GetEtag(channelId, true, false, true)
assert.Equal(t, expected, etag)
mockStore.AutoTranslation().(*mocks.AutoTranslationStore).AssertNumberOfCalls(t, "GetLatestPostUpdateAtForChannel", 1)
})
t.Run("GetEtag: invalidate translation etag clears the channel cache", func(t *testing.T) {
mockStore := getMockStore(t)
mockCacheProvider := getMockCacheProvider()
cachedStore, err := NewLocalCacheLayer(mockStore, nil, nil, mockCacheProvider, logger)
require.NoError(t, err)
// Call GetEtag with includeTranslations=true
expected := fmt.Sprintf("%v.%v_%v", model.CurrentVersion, fakeLastTime, 5000)
etag := cachedStore.Post().GetEtag(channelId, true, false, true)
assert.Equal(t, expected, etag)
mockStore.AutoTranslation().(*mocks.AutoTranslationStore).AssertNumberOfCalls(t, "GetLatestPostUpdateAtForChannel", 1)
// Invalidate the channel's post translation etag
cachedStore.AutoTranslation().InvalidatePostTranslationEtag(channelId)
// Call GetEtag again - should re-fetch
etag = cachedStore.Post().GetEtag(channelId, true, false, true)
assert.Equal(t, expected, etag)
mockStore.AutoTranslation().(*mocks.AutoTranslationStore).AssertNumberOfCalls(t, "GetLatestPostUpdateAtForChannel", 2)
})
}
func TestPostStoreCache(t *testing.T) {

View file

@ -914,27 +914,6 @@ func (s *RetryLayerAutoTranslationStore) GetActiveDestinationLanguages(channelID
}
func (s *RetryLayerAutoTranslationStore) GetAllByStatePage(state model.TranslationState, offset int, limit int) ([]*model.Translation, error) {
tries := 0
for {
result, err := s.AutoTranslationStore.GetAllByStatePage(state, offset, limit)
if err == nil {
return result, nil
}
if !isRepeatableError(err) {
return result, err
}
tries++
if tries >= 3 {
err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures")
return result, err
}
timepkg.Sleep(100 * timepkg.Millisecond)
}
}
func (s *RetryLayerAutoTranslationStore) GetAllForObject(objectType string, objectID string) ([]*model.Translation, error) {
tries := 0
@ -998,6 +977,27 @@ func (s *RetryLayerAutoTranslationStore) GetByStateOlderThan(state model.Transla
}
func (s *RetryLayerAutoTranslationStore) GetLatestPostUpdateAtForChannel(channelID string) (int64, error) {
tries := 0
for {
result, err := s.AutoTranslationStore.GetLatestPostUpdateAtForChannel(channelID)
if err == nil {
return result, nil
}
if !isRepeatableError(err) {
return result, err
}
tries++
if tries >= 3 {
err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures")
return result, err
}
timepkg.Sleep(100 * timepkg.Millisecond)
}
}
func (s *RetryLayerAutoTranslationStore) GetUserLanguage(userID string, channelID string) (string, error) {
tries := 0
@ -1019,6 +1019,12 @@ func (s *RetryLayerAutoTranslationStore) GetUserLanguage(userID string, channelI
}
func (s *RetryLayerAutoTranslationStore) InvalidatePostTranslationEtag(channelID string) {
s.AutoTranslationStore.InvalidatePostTranslationEtag(channelID)
}
func (s *RetryLayerAutoTranslationStore) InvalidateUserAutoTranslation(userID string, channelID string) {
s.AutoTranslationStore.InvalidateUserAutoTranslation(userID, channelID)
@ -8114,9 +8120,9 @@ func (s *RetryLayerPostStore) GetEditHistoryForPost(postID string) ([]*model.Pos
}
func (s *RetryLayerPostStore) GetEtag(channelID string, allowFromCache bool, collapsedThreads bool) string {
func (s *RetryLayerPostStore) GetEtag(channelID string, allowFromCache bool, collapsedThreads bool, includeTranslations bool) string {
return s.PostStore.GetEtag(channelID, allowFromCache, collapsedThreads)
return s.PostStore.GetEtag(channelID, allowFromCache, collapsedThreads, includeTranslations)
}

View file

@ -24,6 +24,7 @@ type TranslationMeta json.RawMessage
type Translation struct {
ObjectType string
ObjectID string
ChannelID *string
DstLang string
ProviderID string
NormHash string
@ -294,6 +295,11 @@ func (s *SqlAutoTranslationStore) Save(translation *model.Translation) error {
objectType = model.TranslationObjectTypePost
}
var channelID *string
if translation.ChannelID != "" {
channelID = &translation.ChannelID
}
objectID := translation.ObjectID
// Preserve existing Meta fields and add/override "type"
@ -321,10 +327,11 @@ func (s *SqlAutoTranslationStore) Save(translation *model.Translation) error {
query := s.getQueryBuilder().
Insert("Translations").
Columns("ObjectId", "DstLang", "ObjectType", "ProviderId", "NormHash", "Text", "Confidence", "Meta", "State", "UpdateAt").
Values(objectID, dstLang, objectType, providerID, translation.NormHash, text, confidence, metaBytes, string(translation.State), now).
Columns("ObjectId", "DstLang", "ObjectType", "ChannelId", "ProviderId", "NormHash", "Text", "Confidence", "Meta", "State", "UpdateAt").
Values(objectID, dstLang, objectType, channelID, providerID, translation.NormHash, text, confidence, metaBytes, string(translation.State), now).
Suffix(`ON CONFLICT (ObjectId, ObjectType, dstLang)
DO UPDATE SET
ChannelId = EXCLUDED.ChannelId,
ProviderId = EXCLUDED.ProviderId,
NormHash = EXCLUDED.NormHash,
Text = EXCLUDED.Text,
@ -342,66 +349,6 @@ func (s *SqlAutoTranslationStore) Save(translation *model.Translation) error {
return nil
}
func (s *SqlAutoTranslationStore) GetAllByStatePage(state model.TranslationState, offset int, limit int) ([]*model.Translation, error) {
query := s.getQueryBuilder().
Select("ObjectType", "ObjectId", "DstLang", "ProviderId", "NormHash", "Text", "Confidence", "Meta", "State", "UpdateAt").
From("Translations").
Where(sq.Eq{"State": string(state)}).
OrderBy("UpdateAt ASC").
Limit(uint64(limit)).
Offset(uint64(offset))
var translations []Translation
if err := s.GetReplica().SelectBuilder(&translations, query); err != nil {
return nil, errors.Wrapf(err, "failed to get translations by state=%s", state)
}
result := make([]*model.Translation, 0, len(translations))
for _, t := range translations {
var translationTypeStr string
meta, err := t.Meta.ToMap()
if err != nil {
// Log error but continue with other translations
continue
}
if v, ok := meta["type"]; ok {
if s, ok := v.(string); ok {
translationTypeStr = s
}
}
// Default objectType to "post" if not set
objectType := t.ObjectType
if objectType == "" {
objectType = model.TranslationObjectTypePost
}
modelT := &model.Translation{
ObjectID: t.ObjectID,
ObjectType: objectType,
Lang: t.DstLang,
Type: model.TranslationType(translationTypeStr),
Confidence: t.Confidence,
State: model.TranslationState(t.State),
NormHash: t.NormHash,
Meta: meta,
UpdateAt: t.UpdateAt,
}
if modelT.Type == model.TranslationTypeObject {
modelT.ObjectJSON = json.RawMessage(t.Text)
} else {
modelT.Text = t.Text
}
result = append(result, modelT)
}
return result, nil
}
func (s *SqlAutoTranslationStore) GetByStateOlderThan(state model.TranslationState, olderThanMillis int64, limit int) ([]*model.Translation, error) {
query := s.getQueryBuilder().
Select("ObjectType", "ObjectId", "DstLang", "ProviderId", "NormHash", "Text", "Confidence", "Meta", "State", "UpdateAt").
@ -467,3 +414,23 @@ func (s *SqlAutoTranslationStore) ClearCaches() {}
func (s *SqlAutoTranslationStore) InvalidateUserAutoTranslation(userID, channelID string) {}
func (s *SqlAutoTranslationStore) InvalidateUserLocaleCache(userID string) {}
// GetLatestPostUpdateAtForChannel returns the most recent updateAt timestamp for post translations
// in the given channel (across all locales). Uses a direct lookup on the channelid column
// for O(1) performance. Returns 0 if no translations exist.
func (s *SqlAutoTranslationStore) GetLatestPostUpdateAtForChannel(channelID string) (int64, error) {
query := s.getQueryBuilder().
Select("COALESCE(MAX(updateAt), 0)").
From("translations").
Where(sq.Eq{"channelid": channelID}).
Where(sq.Eq{"objectType": model.TranslationObjectTypePost})
var updateAt int64
if err := s.GetReplica().GetBuilder(&updateAt, query); err != nil {
return 0, errors.Wrapf(err, "failed to get latest translation updateAt for channel_id=%s", channelID)
}
return updateAt, nil
}
func (s *SqlAutoTranslationStore) InvalidatePostTranslationEtag(channelID string) {}

View file

@ -938,7 +938,7 @@ func (s *SqlPostStore) InvalidateLastPostTimeCache(channelId string) {
}
//nolint:unparam
func (s *SqlPostStore) GetEtag(channelId string, allowFromCache, collapsedThreads bool) string {
func (s *SqlPostStore) GetEtag(channelId string, allowFromCache, collapsedThreads bool, includeTranslations bool) string {
q := s.getQueryBuilder().Select("Id", "UpdateAt").From("Posts").Where(sq.Eq{"ChannelId": channelId}).OrderBy("UpdateAt DESC").Limit(1)
if collapsedThreads {
q.Where(sq.Eq{"RootId": ""})

View file

@ -391,7 +391,7 @@ type PostStore interface {
GetPostAfterTime(channelID string, timestamp int64, collapsedThreads bool) (*model.Post, error)
GetPostIdAfterTime(channelID string, timestamp int64, collapsedThreads bool) (string, error)
GetPostIdBeforeTime(channelID string, timestamp int64, collapsedThreads bool) (string, error)
GetEtag(channelID string, allowFromCache bool, collapsedThreads bool) string
GetEtag(channelID string, allowFromCache bool, collapsedThreads bool, includeTranslations bool) string
Search(teamID string, userID string, params *model.SearchParams) (*model.PostList, error)
AnalyticsUserCountsWithPostsByDay(teamID string) (model.AnalyticsRows, error)
AnalyticsPostCountsByDay(options *model.AnalyticsPostCountsOptions) (model.AnalyticsRows, error)
@ -1166,7 +1166,6 @@ type AutoTranslationStore interface {
GetBatch(objectType string, objectIDs []string, dstLang string) (map[string]*model.Translation, error)
GetAllForObject(objectType, objectID string) ([]*model.Translation, error)
Save(translation *model.Translation) error
GetAllByStatePage(state model.TranslationState, offset, limit int) ([]*model.Translation, error)
GetByStateOlderThan(state model.TranslationState, olderThanMillis int64, limit int) ([]*model.Translation, error)
ClearCaches()
@ -1176,6 +1175,12 @@ type AutoTranslationStore interface {
// InvalidateUserLocaleCache invalidates all language caches for a user across all channels.
// This is called when a user changes their locale preference.
InvalidateUserLocaleCache(userID string)
// GetLatestPostUpdateAtForChannel returns the most recent updateAt timestamp for post translations
// in the given channel (across all locales). Returns 0 if no translations exist.
GetLatestPostUpdateAtForChannel(channelID string) (int64, error)
// InvalidatePostTranslationEtag invalidates the cached post translation etag for a channel.
// This should be called after saving a new post translation.
InvalidatePostTranslationEtag(channelID string)
}
type ContentFlaggingStore interface {

View file

@ -79,36 +79,6 @@ func (_m *AutoTranslationStore) GetActiveDestinationLanguages(channelID string,
return r0, r1
}
// GetAllByStatePage provides a mock function with given fields: state, offset, limit
func (_m *AutoTranslationStore) GetAllByStatePage(state model.TranslationState, offset int, limit int) ([]*model.Translation, error) {
ret := _m.Called(state, offset, limit)
if len(ret) == 0 {
panic("no return value specified for GetAllByStatePage")
}
var r0 []*model.Translation
var r1 error
if rf, ok := ret.Get(0).(func(model.TranslationState, int, int) ([]*model.Translation, error)); ok {
return rf(state, offset, limit)
}
if rf, ok := ret.Get(0).(func(model.TranslationState, int, int) []*model.Translation); ok {
r0 = rf(state, offset, limit)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]*model.Translation)
}
}
if rf, ok := ret.Get(1).(func(model.TranslationState, int, int) error); ok {
r1 = rf(state, offset, limit)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GetAllForObject provides a mock function with given fields: objectType, objectID
func (_m *AutoTranslationStore) GetAllForObject(objectType string, objectID string) ([]*model.Translation, error) {
ret := _m.Called(objectType, objectID)
@ -199,6 +169,34 @@ func (_m *AutoTranslationStore) GetByStateOlderThan(state model.TranslationState
return r0, r1
}
// GetLatestPostUpdateAtForChannel provides a mock function with given fields: channelID
func (_m *AutoTranslationStore) GetLatestPostUpdateAtForChannel(channelID string) (int64, error) {
ret := _m.Called(channelID)
if len(ret) == 0 {
panic("no return value specified for GetLatestPostUpdateAtForChannel")
}
var r0 int64
var r1 error
if rf, ok := ret.Get(0).(func(string) (int64, error)); ok {
return rf(channelID)
}
if rf, ok := ret.Get(0).(func(string) int64); ok {
r0 = rf(channelID)
} else {
r0 = ret.Get(0).(int64)
}
if rf, ok := ret.Get(1).(func(string) error); ok {
r1 = rf(channelID)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GetUserLanguage provides a mock function with given fields: userID, channelID
func (_m *AutoTranslationStore) GetUserLanguage(userID string, channelID string) (string, error) {
ret := _m.Called(userID, channelID)
@ -227,6 +225,11 @@ func (_m *AutoTranslationStore) GetUserLanguage(userID string, channelID string)
return r0, r1
}
// InvalidatePostTranslationEtag provides a mock function with given fields: channelID
func (_m *AutoTranslationStore) InvalidatePostTranslationEtag(channelID string) {
_m.Called(channelID)
}
// InvalidateUserAutoTranslation provides a mock function with given fields: userID, channelID
func (_m *AutoTranslationStore) InvalidateUserAutoTranslation(userID string, channelID string) {
_m.Called(userID, channelID)
@ -283,24 +286,6 @@ func (_m *AutoTranslationStore) Save(translation *model.Translation) error {
return r0
}
// SetUserEnabled provides a mock function with given fields: userID, channelID, enabled
func (_m *AutoTranslationStore) SetUserEnabled(userID string, channelID string, enabled bool) error {
ret := _m.Called(userID, channelID, enabled)
if len(ret) == 0 {
panic("no return value specified for SetUserEnabled")
}
var r0 error
if rf, ok := ret.Get(0).(func(string, string, bool) error); ok {
r0 = rf(userID, channelID, enabled)
} else {
r0 = ret.Error(0)
}
return r0
}
// NewAutoTranslationStore creates a new instance of AutoTranslationStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewAutoTranslationStore(t interface {

View file

@ -264,17 +264,17 @@ func (_m *PostStore) GetEditHistoryForPost(postID string) ([]*model.Post, error)
return r0, r1
}
// GetEtag provides a mock function with given fields: channelID, allowFromCache, collapsedThreads
func (_m *PostStore) GetEtag(channelID string, allowFromCache bool, collapsedThreads bool) string {
ret := _m.Called(channelID, allowFromCache, collapsedThreads)
// GetEtag provides a mock function with given fields: channelID, allowFromCache, collapsedThreads, includeTranslations
func (_m *PostStore) GetEtag(channelID string, allowFromCache bool, collapsedThreads bool, includeTranslations bool) string {
ret := _m.Called(channelID, allowFromCache, collapsedThreads, includeTranslations)
if len(ret) == 0 {
panic("no return value specified for GetEtag")
}
var r0 string
if rf, ok := ret.Get(0).(func(string, bool, bool) string); ok {
r0 = rf(channelID, allowFromCache, collapsedThreads)
if rf, ok := ret.Get(0).(func(string, bool, bool, bool) string); ok {
r0 = rf(channelID, allowFromCache, collapsedThreads, includeTranslations)
} else {
r0 = ret.Get(0).(string)
}

View file

@ -644,13 +644,13 @@ func testPostStoreGet(t *testing.T, rctx request.CTX, ss store.Store) {
o1.UserId = model.NewId()
o1.Message = NewTestID()
etag1 := ss.Post().GetEtag(o1.ChannelId, false, false)
etag1 := ss.Post().GetEtag(o1.ChannelId, false, false, false)
require.Equal(t, 0, strings.Index(etag1, model.CurrentVersion+"."), "Invalid Etag")
o1, err = ss.Post().Save(rctx, o1)
require.NoError(t, err)
etag2 := ss.Post().GetEtag(o1.ChannelId, false, false)
etag2 := ss.Post().GetEtag(o1.ChannelId, false, false, false)
require.Equal(t, 0, strings.Index(etag2, fmt.Sprintf("%v.%v", model.CurrentVersion, o1.UpdateAt)), "Invalid Etag")
r1, err := ss.Post().Get(rctx, o1.Id, model.GetPostsOptions{}, "", map[string]bool{})
@ -1224,7 +1224,7 @@ func testPostStoreDelete(t *testing.T, rctx request.CTX, ss store.Store) {
require.NoError(t, err)
// Verify etag generation for the channel containing the post.
etag1 := ss.Post().GetEtag(rootPost.ChannelId, false, false)
etag1 := ss.Post().GetEtag(rootPost.ChannelId, false, false, false)
require.Equal(t, 0, strings.Index(etag1, model.CurrentVersion+"."), "Invalid Etag")
// Verify the created post.
@ -1250,7 +1250,7 @@ func testPostStoreDelete(t *testing.T, rctx request.CTX, ss store.Store) {
require.IsType(t, &store.ErrNotFound{}, err)
// Verify etag generation for the channel containing the now deleted post.
etag2 := ss.Post().GetEtag(rootPost.ChannelId, false, false)
etag2 := ss.Post().GetEtag(rootPost.ChannelId, false, false, false)
require.Equal(t, 0, strings.Index(etag2, model.CurrentVersion+"."), "Invalid Etag")
})

View file

@ -836,22 +836,6 @@ func (s *TimerLayerAutoTranslationStore) GetActiveDestinationLanguages(channelID
return result, err
}
func (s *TimerLayerAutoTranslationStore) GetAllByStatePage(state model.TranslationState, offset int, limit int) ([]*model.Translation, error) {
start := time.Now()
result, err := s.AutoTranslationStore.GetAllByStatePage(state, offset, limit)
elapsed := float64(time.Since(start)) / float64(time.Second)
if s.Root.Metrics != nil {
success := "false"
if err == nil {
success = "true"
}
s.Root.Metrics.ObserveStoreMethodDuration("AutoTranslationStore.GetAllByStatePage", success, elapsed)
}
return result, err
}
func (s *TimerLayerAutoTranslationStore) GetAllForObject(objectType string, objectID string) ([]*model.Translation, error) {
start := time.Now()
@ -900,6 +884,22 @@ func (s *TimerLayerAutoTranslationStore) GetByStateOlderThan(state model.Transla
return result, err
}
func (s *TimerLayerAutoTranslationStore) GetLatestPostUpdateAtForChannel(channelID string) (int64, error) {
start := time.Now()
result, err := s.AutoTranslationStore.GetLatestPostUpdateAtForChannel(channelID)
elapsed := float64(time.Since(start)) / float64(time.Second)
if s.Root.Metrics != nil {
success := "false"
if err == nil {
success = "true"
}
s.Root.Metrics.ObserveStoreMethodDuration("AutoTranslationStore.GetLatestPostUpdateAtForChannel", success, elapsed)
}
return result, err
}
func (s *TimerLayerAutoTranslationStore) GetUserLanguage(userID string, channelID string) (string, error) {
start := time.Now()
@ -916,6 +916,21 @@ func (s *TimerLayerAutoTranslationStore) GetUserLanguage(userID string, channelI
return result, err
}
func (s *TimerLayerAutoTranslationStore) InvalidatePostTranslationEtag(channelID string) {
start := time.Now()
s.AutoTranslationStore.InvalidatePostTranslationEtag(channelID)
elapsed := float64(time.Since(start)) / float64(time.Second)
if s.Root.Metrics != nil {
success := "false"
if true {
success = "true"
}
s.Root.Metrics.ObserveStoreMethodDuration("AutoTranslationStore.InvalidatePostTranslationEtag", success, elapsed)
}
}
func (s *TimerLayerAutoTranslationStore) InvalidateUserAutoTranslation(userID string, channelID string) {
start := time.Now()
@ -6500,10 +6515,10 @@ func (s *TimerLayerPostStore) GetEditHistoryForPost(postID string) ([]*model.Pos
return result, err
}
func (s *TimerLayerPostStore) GetEtag(channelID string, allowFromCache bool, collapsedThreads bool) string {
func (s *TimerLayerPostStore) GetEtag(channelID string, allowFromCache bool, collapsedThreads bool, includeTranslations bool) string {
start := time.Now()
result := s.PostStore.GetEtag(channelID, allowFromCache, collapsedThreads)
result := s.PostStore.GetEtag(channelID, allowFromCache, collapsedThreads, includeTranslations)
elapsed := float64(time.Since(start)) / float64(time.Second)
if s.Root.Metrics != nil {

View file

@ -256,26 +256,6 @@ func (_m *AutoTranslationInterface) MakeWorker() model.Worker {
return r0
}
// SetUserEnabled provides a mock function with given fields: channelID, userID, enabled
func (_m *AutoTranslationInterface) SetUserEnabled(channelID string, userID string, enabled bool) *model.AppError {
ret := _m.Called(channelID, userID, enabled)
if len(ret) == 0 {
panic("no return value specified for SetUserEnabled")
}
var r0 *model.AppError
if rf, ok := ret.Get(0).(func(string, string, bool) *model.AppError); ok {
r0 = rf(channelID, userID, enabled)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.AppError)
}
}
return r0
}
// Shutdown provides a mock function with no fields
func (_m *AutoTranslationInterface) Shutdown() error {
ret := _m.Called()

View file

@ -36,6 +36,7 @@ const (
type Translation struct {
ObjectID string `json:"object_id"`
ObjectType string `json:"object_type"`
ChannelID string `json:"channel_id,omitempty"` // Channel ID for efficient queries
Lang string `json:"lang"`
Provider string `json:"provider"`
Type TranslationType `json:"type"`
@ -71,6 +72,7 @@ func (t *Translation) Clone() *Translation {
return &Translation{
ObjectID: t.ObjectID,
ObjectType: t.ObjectType,
ChannelID: t.ChannelID,
Lang: t.Lang,
Provider: t.Provider,
Type: t.Type,

View file

@ -46,6 +46,7 @@ const (
ClusterEventPluginEvent ClusterEvent = "plugin_event"
ClusterEventInvalidateCacheForTermsOfService ClusterEvent = "inv_terms_of_service"
ClusterEventInvalidateCacheForUserAutoTranslation ClusterEvent = "inv_user_autotranslation"
ClusterEventInvalidateCacheForPostTranslationEtag ClusterEvent = "inv_post_translation_etag"
ClusterEventAutoTranslationTask ClusterEvent = "autotranslation_task"
ClusterEventBusyStateChanged ClusterEvent = "busy_state_change"
// Note: if you are adding a new event, please also add it in the slice of