diff --git a/e2e-tests/cypress/tests/support/api/cloud_default_config.json b/e2e-tests/cypress/tests/support/api/cloud_default_config.json index 5d39dd1110d..6085054e1f1 100644 --- a/e2e-tests/cypress/tests/support/api/cloud_default_config.json +++ b/e2e-tests/cypress/tests/support/api/cloud_default_config.json @@ -292,6 +292,7 @@ "PublicCertificateFile": "", "PrivateKeyFile": "", "QueryTimeout": 60, + "AnalyticsQueryTimeout": 300, "MaxPageSize": 500, "LoginFieldName": "", "LoginButtonColor": "#0000", diff --git a/e2e-tests/cypress/tests/support/api/on_prem_default_config.json b/e2e-tests/cypress/tests/support/api/on_prem_default_config.json index 7fcc0482d49..a3d2d5f2a57 100644 --- a/e2e-tests/cypress/tests/support/api/on_prem_default_config.json +++ b/e2e-tests/cypress/tests/support/api/on_prem_default_config.json @@ -152,6 +152,7 @@ "Trace": false, "AtRestEncryptKey": "", "QueryTimeout": 30, + "AnalyticsQueryTimeout": 300, "DisableDatabaseSearch": false, "MigrationsStatementTimeoutSeconds": 100000, "ReplicaLagSettings": [], diff --git a/e2e-tests/playwright/lib/src/server/default_config.ts b/e2e-tests/playwright/lib/src/server/default_config.ts index 0f5982cf983..b893f626120 100644 --- a/e2e-tests/playwright/lib/src/server/default_config.ts +++ b/e2e-tests/playwright/lib/src/server/default_config.ts @@ -259,6 +259,7 @@ const defaultServerConfig: AdminConfig = { Trace: false, AtRestEncryptKey: '', QueryTimeout: 30, + AnalyticsQueryTimeout: 300, DisableDatabaseSearch: false, MigrationsStatementTimeoutSeconds: 100000, ReplicaLagSettings: [], diff --git a/server/channels/store/sqlstore/file_info_store.go b/server/channels/store/sqlstore/file_info_store.go index 753a6b38317..ea84b5b6847 100644 --- a/server/channels/store/sqlstore/file_info_store.go +++ b/server/channels/store/sqlstore/file_info_store.go @@ -794,12 +794,15 @@ func (fs SqlFileInfoStore) RestoreForPostByIds(rctx request.CTX, postId string, } func (fs SqlFileInfoStore) RefreshFileStats() error { + ctx, cancel := fs.analyticsContext() + defer cancel() + // CONCURRENTLY is not used deliberately because as per Postgres docs, // not using CONCURRENTLY takes less resources and completes faster // at the expense of locking the mat view. Since viewing admin console // is not a very frequent activity, we accept the tradeoff to let the // refresh happen as fast as possible. - if _, err := fs.GetMaster().Exec("REFRESH MATERIALIZED VIEW file_stats"); err != nil { + if _, err := fs.GetMaster().ExecContext(ctx, "REFRESH MATERIALIZED VIEW file_stats"); err != nil { return errors.Wrap(err, "error refreshing materialized view file_stats") } diff --git a/server/channels/store/sqlstore/post_store.go b/server/channels/store/sqlstore/post_store.go index 69c53c83181..e782f56be03 100644 --- a/server/channels/store/sqlstore/post_store.go +++ b/server/channels/store/sqlstore/post_store.go @@ -3267,11 +3267,18 @@ func (s *SqlPostStore) RefreshPostStats() error { // at the expense of locking the mat view. Since viewing admin console // is not a very frequent activity, we accept the tradeoff to let the // refresh happen as fast as possible. - if _, err := s.GetMaster().Exec("REFRESH MATERIALIZED VIEW posts_by_team_day"); err != nil { + + postsCtx, postsCancel := s.analyticsContext() + defer postsCancel() + + if _, err := s.GetMaster().ExecContext(postsCtx, "REFRESH MATERIALIZED VIEW posts_by_team_day"); err != nil { return errors.Wrap(err, "error refreshing materialized view posts_by_team_day") } - if _, err := s.GetMaster().Exec("REFRESH MATERIALIZED VIEW bot_posts_by_team_day"); err != nil { + botPostsCtx, botPostsCancel := s.analyticsContext() + defer botPostsCancel() + + if _, err := s.GetMaster().ExecContext(botPostsCtx, "REFRESH MATERIALIZED VIEW bot_posts_by_team_day"); err != nil { return errors.Wrap(err, "error refreshing materialized view bot_posts_by_team_day") } diff --git a/server/channels/store/sqlstore/store.go b/server/channels/store/sqlstore/store.go index a806e8d956f..f63b76ec6be 100644 --- a/server/channels/store/sqlstore/store.go +++ b/server/channels/store/sqlstore/store.go @@ -4,6 +4,7 @@ package sqlstore import ( + "context" "database/sql" "fmt" "path" @@ -474,6 +475,10 @@ func (ss *SqlStore) GetReplica() *sqlxDBWrapper { return ss.GetMaster() } +func (ss *SqlStore) analyticsContext() (context.Context, context.CancelFunc) { + return context.WithTimeout(context.Background(), time.Duration(*ss.settings.AnalyticsQueryTimeout)*time.Second) +} + func (ss *SqlStore) monitorReplicas() { t := time.NewTicker(time.Duration(*ss.settings.ReplicaMonitorIntervalSeconds) * time.Second) defer func() { diff --git a/server/channels/store/sqlstore/user_store.go b/server/channels/store/sqlstore/user_store.go index 82f535db130..523355432d7 100644 --- a/server/channels/store/sqlstore/user_store.go +++ b/server/channels/store/sqlstore/user_store.go @@ -2374,9 +2374,13 @@ func (us SqlUserStore) GetUsersWithInvalidEmails(page int, perPage int, restrict } func (us SqlUserStore) RefreshPostStatsForUsers() error { - if _, err := us.GetMaster().Exec("REFRESH MATERIALIZED VIEW poststats"); err != nil { + ctx, cancel := us.analyticsContext() + defer cancel() + + if _, err := us.GetMaster().ExecContext(ctx, "REFRESH MATERIALIZED VIEW poststats"); err != nil { return errors.Wrap(err, "users_refresh_post_stats_exec") } + return nil } diff --git a/server/channels/store/storetest/settings.go b/server/channels/store/storetest/settings.go index 900ab444dd5..92e59c97687 100644 --- a/server/channels/store/storetest/settings.go +++ b/server/channels/store/storetest/settings.go @@ -90,6 +90,7 @@ func databaseSettings(driver, dataSource string) *model.SqlSettings { Trace: model.NewPointer(false), AtRestEncryptKey: model.NewPointer(model.NewRandomString(32)), QueryTimeout: new(int), + AnalyticsQueryTimeout: new(int), MigrationsStatementTimeoutSeconds: new(int), } *settings.MaxIdleConns = 10 @@ -97,6 +98,7 @@ func databaseSettings(driver, dataSource string) *model.SqlSettings { *settings.ConnMaxIdleTimeMilliseconds = 300000 *settings.MaxOpenConns = 100 *settings.QueryTimeout = 60 + *settings.AnalyticsQueryTimeout = 300 *settings.MigrationsStatementTimeoutSeconds = 60 return settings diff --git a/server/i18n/en.json b/server/i18n/en.json index 848966cfb60..707536fadcd 100644 --- a/server/i18n/en.json +++ b/server/i18n/en.json @@ -11028,6 +11028,10 @@ "id": "model.config.is_valid.sitename_length.app_error", "translation": "Site name must be less than or equal to {{.MaxLength}} characters." }, + { + "id": "model.config.is_valid.sql_analytics_query_timeout.app_error", + "translation": "Invalid analytics query timeout for SQL settings. Must be a positive number." + }, { "id": "model.config.is_valid.sql_conn_max_idle_time_milliseconds.app_error", "translation": "Invalid connection maximum idle time for SQL settings. Must be a non-negative number." diff --git a/server/public/model/config.go b/server/public/model/config.go index 338be363b77..3ec6d8253ca 100644 --- a/server/public/model/config.go +++ b/server/public/model/config.go @@ -1491,6 +1491,7 @@ type SqlSettings struct { Trace *bool `access:"environment_database,write_restrictable,cloud_restrictable"` AtRestEncryptKey *string `access:"environment_database,write_restrictable,cloud_restrictable"` // telemetry: none QueryTimeout *int `access:"environment_database,write_restrictable,cloud_restrictable"` + AnalyticsQueryTimeout *int `access:"environment_database,write_restrictable,cloud_restrictable"` DisableDatabaseSearch *bool `access:"environment_database,write_restrictable,cloud_restrictable"` MigrationsStatementTimeoutSeconds *int `access:"environment_database,write_restrictable,cloud_restrictable"` ReplicaLagSettings []*ReplicaLagSettings `access:"environment_database,write_restrictable,cloud_restrictable"` // telemetry: none @@ -1548,6 +1549,10 @@ func (s *SqlSettings) SetDefaults(isUpdate bool) { s.QueryTimeout = NewPointer(30) } + if s.AnalyticsQueryTimeout == nil { + s.AnalyticsQueryTimeout = NewPointer(300) + } + if s.DisableDatabaseSearch == nil { s.DisableDatabaseSearch = NewPointer(false) } @@ -4366,6 +4371,10 @@ func (s *SqlSettings) isValid() *AppError { return NewAppError("Config.IsValid", "model.config.is_valid.sql_query_timeout.app_error", nil, "", http.StatusBadRequest) } + if *s.AnalyticsQueryTimeout <= 0 { + return NewAppError("Config.IsValid", "model.config.is_valid.sql_analytics_query_timeout.app_error", nil, "", http.StatusBadRequest) + } + if *s.DataSource == "" { return NewAppError("Config.IsValid", "model.config.is_valid.sql_data_src.app_error", nil, "", http.StatusBadRequest) } diff --git a/webapp/channels/src/components/admin_console/__snapshots__/database_settings.test.tsx.snap b/webapp/channels/src/components/admin_console/__snapshots__/database_settings.test.tsx.snap index 08a37e8882e..ab2589c7f54 100644 --- a/webapp/channels/src/components/admin_console/__snapshots__/database_settings.test.tsx.snap +++ b/webapp/channels/src/components/admin_console/__snapshots__/database_settings.test.tsx.snap @@ -168,6 +168,37 @@ exports[`components/DatabaseSettings should match snapshot 1`] = ` +
+ +
+ +
+ The number of seconds to wait for a response from the database after opening a connection and sending certain analytics queries. This setting only applies to long queries which are run in the background to populate some information in the Team and Site Statistics pages. +
+
+
{ DisableDatabaseSearch: true, DataSource: 'postgres://mmuser:mostest@localhost/mattermost_test?sslmode=disable\u0026connect_timeout=10', QueryTimeout: 10, + AnalyticsQueryTimeout: 300, ConnMaxLifetimeMilliseconds: 10, ConnMaxIdleTimeMilliseconds: 20, }, diff --git a/webapp/channels/src/components/admin_console/database_settings.tsx b/webapp/channels/src/components/admin_console/database_settings.tsx index 5d1ac1c8ef1..b770757ee08 100644 --- a/webapp/channels/src/components/admin_console/database_settings.tsx +++ b/webapp/channels/src/components/admin_console/database_settings.tsx @@ -36,6 +36,7 @@ interface State extends BaseState { trace: boolean; disableDatabaseSearch: boolean; queryTimeout: number; + analyticsQueryTimeout: number; connMaxLifetimeMilliseconds: number; connMaxIdleTimeMilliseconds: number; minimumHashtagLength: number; @@ -62,6 +63,8 @@ const messages = defineMessages({ maxOpenDescription: {id: 'admin.sql.maxOpenDescription', defaultMessage: 'Maximum number of open connections held open to the database.'}, queryTimeoutTitle: {id: 'admin.sql.queryTimeoutTitle', defaultMessage: 'Query Timeout:'}, queryTimeoutDescription: {id: 'admin.sql.queryTimeoutDescription', defaultMessage: 'The number of seconds to wait for a response from the database after opening a connection and sending the query. Errors that you see in the UI or in the logs as a result of a query timeout can vary depending on the type of query.'}, + analyticsQueryTimeoutTitle: {id: 'admin.sql.analyticsQueryTimeoutTitle', defaultMessage: 'Analytics Query Timeout:'}, + analyticsQueryTimeoutDescription: {id: 'admin.sql.analyticsQueryTimeoutDescription', defaultMessage: 'The number of seconds to wait for a response from the database after opening a connection and sending certain analytics queries. This setting only applies to long queries which are run in the background to populate some information in the Team and Site Statistics pages.'}, connMaxLifetimeTitle: {id: 'admin.sql.connMaxLifetimeTitle', defaultMessage: 'Maximum Connection Lifetime:'}, connMaxLifetimeDescription: {id: 'admin.sql.connMaxLifetimeDescription', defaultMessage: 'Maximum lifetime for a connection to the database in milliseconds.'}, connMaxIdleTimeTitle: {id: 'admin.sql.connMaxIdleTimeTitle', defaultMessage: 'Maximum Connection Idle Time:'}, @@ -91,6 +94,8 @@ export const searchableStrings: Array { config.SqlSettings.Trace = this.state.trace; config.SqlSettings.DisableDatabaseSearch = this.state.disableDatabaseSearch; config.SqlSettings.QueryTimeout = this.parseIntNonZero(this.state.queryTimeout); + config.SqlSettings.AnalyticsQueryTimeout = this.parseIntNonZero(this.state.analyticsQueryTimeout); config.SqlSettings.ConnMaxLifetimeMilliseconds = this.parseIntNonNegative(this.state.connMaxLifetimeMilliseconds); config.SqlSettings.ConnMaxIdleTimeMilliseconds = this.parseIntNonNegative(this.state.connMaxIdleTimeMilliseconds); config.ServiceSettings.MinimumHashtagLength = this.parseIntNonZero(this.state.minimumHashtagLength, 3, 2); @@ -144,6 +150,7 @@ export default class DatabaseSettings extends OLDAdminSettings { trace: config.SqlSettings.Trace, disableDatabaseSearch: config.SqlSettings.DisableDatabaseSearch, queryTimeout: config.SqlSettings.QueryTimeout, + analyticsQueryTimeout: config.SqlSettings.AnalyticsQueryTimeout, connMaxLifetimeMilliseconds: config.SqlSettings.ConnMaxLifetimeMilliseconds, connMaxIdleTimeMilliseconds: config.SqlSettings.ConnMaxIdleTimeMilliseconds, minimumHashtagLength: config.ServiceSettings.MinimumHashtagLength, @@ -284,6 +291,21 @@ export default class DatabaseSettings extends OLDAdminSettings { disabled={this.props.isDisabled} type='text' /> + + } + placeholder={defineMessage({id: 'admin.sql.analyticsQueryTimeoutExample', defaultMessage: 'E.g.: "300"'})} + helpText={ + + } + value={this.state.analyticsQueryTimeout} + onChange={this.handleChange} + setByEnv={this.isSetByEnv('SqlSettings.AnalyticsQueryTimeout')} + disabled={this.props.isDisabled} + type='text' + />