Files
cdc10d47fe [MM-69183] Gate expensive user/guest count queries behind admin check in getServerLimits (#36999)
The GET /api/v4/limits/server endpoint ran expensive active-user and
single-channel-guest count queries for every user on login and app
refresh, even though non-admins never receive those counts. The
single-channel-guest count is a full ChannelMembers scan, so this put a
full-table-scan query on a high-frequency hot path.

Add an includeUserCounts parameter to App.GetServerLimits so the user
and guest count queries only run when the caller needs them. The api4
handler passes the existing admin check, keeping the cheap
license-derived and post-history fields for all users while skipping the
expensive queries for non-admins. Internal callers that genuinely need
the counts (isAtUserLimit, user creation/activation) pass true.

Co-authored-by: Hanzo Dev <[email protected]>
2026-06-23 11:57:50 +02:00

126 lines
4.2 KiB
Go

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"net/http"
"github.com/mattermost/mattermost/server/public/model"
)
const (
maxUsersLimit = 200
maxUsersHardLimit = 250
)
// GetServerLimits returns the server's seat/post-history limits. The license-derived
// limit fields and post-history fields are always computed (they are cheap and needed
// by all users). The active user and single-channel guest counts are only computed when
// includeUserCounts is true, because those queries are expensive and the counts are only
// consumed by admin-gated UI and internal seat-limit checks. Callers that do not need the
// counts (e.g. non-admin API requests) should pass false to keep the expensive queries off
// the hot path.
func (a *App) GetServerLimits(includeUserCounts bool) (*model.ServerLimits, *model.AppError) {
limits := &model.ServerLimits{}
license := a.License()
if license == nil && maxUsersLimit > 0 {
// Enforce hard-coded limits for unlicensed servers (no grace period).
limits.MaxUsersLimit = maxUsersLimit
limits.MaxUsersHardLimit = maxUsersHardLimit
} else if license != nil && license.IsSeatCountEnforced && license.Features != nil && license.Features.Users != nil {
// Enforce license limits as required by the license with configurable extra users.
licenseUserLimit := int64(*license.Features.Users)
limits.MaxUsersLimit = licenseUserLimit
// Use ExtraUsers if configured, otherwise default to 0 (no extra users)
extraUsers := 0
if license.ExtraUsers != nil {
extraUsers = *license.ExtraUsers
}
limits.MaxUsersHardLimit = licenseUserLimit + int64(extraUsers)
}
// Check if license has post history limits and get the calculated timestamp
if license != nil && license.Limits != nil && license.Limits.PostHistory > 0 {
limits.PostHistoryLimit = license.Limits.PostHistory
// Get the calculated timestamp of the last accessible post
lastAccessibleTime, appErr := a.GetLastAccessiblePostTime()
if appErr != nil {
return nil, appErr
}
limits.LastAccessiblePostTime = lastAccessibleTime
}
// The user/guest count queries are expensive (the single-channel guest count is a
// full ChannelMembers scan). Only run them when the caller actually needs the counts.
if !includeUserCounts {
return limits, nil
}
activeUserCount, appErr := a.Srv().Store().User().Count(model.UserCountOptions{})
if appErr != nil {
return nil, model.NewAppError("GetServerLimits", "app.limits.get_app_limits.user_count.store_error", nil, "", http.StatusInternalServerError).Wrap(appErr)
}
if a.shouldTrackSingleChannelGuests() {
singleChannelGuestCount, err := a.Srv().Store().User().AnalyticsGetSingleChannelGuestCount()
if err != nil {
return nil, model.NewAppError("GetServerLimits", "app.limits.get_app_limits.single_channel_guest_count.store_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
// Single-channel guests are free and excluded from the primary seat count.
limits.ActiveUserCount = max(activeUserCount-singleChannelGuestCount, 0)
limits.SingleChannelGuestCount = singleChannelGuestCount
// Guests are allowed up to a 1:1 ratio with licensed seats.
if license != nil && license.Features != nil && license.Features.Users != nil {
limits.SingleChannelGuestLimit = int64(*license.Features.Users)
}
} else {
limits.ActiveUserCount = activeUserCount
}
return limits, nil
}
func (a *App) shouldTrackSingleChannelGuests() bool {
license := a.License()
if license == nil {
return false
}
if license.IsMattermostEntry() {
return false
}
cfg := a.Config()
if cfg == nil || cfg.GuestAccountsSettings.Enable == nil {
return false
}
return *cfg.GuestAccountsSettings.Enable
}
func (a *App) GetPostHistoryLimit() int64 {
license := a.License()
if license == nil || license.Limits == nil || license.Limits.PostHistory == 0 {
// No limits applicable
return 0
}
return license.Limits.PostHistory
}
func (a *App) isAtUserLimit() (bool, *model.AppError) {
userLimits, appErr := a.GetServerLimits(true)
if appErr != nil {
return false, appErr
}
if userLimits.MaxUsersHardLimit == 0 {
return false, nil
}
return userLimits.ActiveUserCount >= userLimits.MaxUsersHardLimit, appErr
}