fix: drop N+1 db query on template ACL available (#25465)

Fixes
[PLAT-149](https://linear.app/codercom/issue/PLAT-149/template-permissions-search-is-extremely-slow-with-many-groups).

`/acl/available` ran a db query per group. A deployment with >5,000
groups made this route extremely slow.
This commit is contained in:
Steven Masley
2026-05-20 22:40:50 +00:00
committed by GitHub
parent 889add734e
commit 9b6eadab77
12 changed files with 315 additions and 26 deletions
+3 -1
View File
@@ -35,7 +35,9 @@ func TestDynamicParametersOwnerGroups(t *testing.T) {
_, noGroupUser := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID)
// Create the group to be asserted
group := coderdtest.CreateGroup(t, ownerClient, owner.OrganizationID, "bloob", templateAdminUser)
// Make the group name something after "Everyone" when sorted alphabetically.
// The test wants to check that `Everyone` is the default, which is the first alphabetical group in the test.
group := coderdtest.CreateGroup(t, ownerClient, owner.OrganizationID, "zebra", templateAdminUser)
dynamicParametersTerraformSource, err := os.ReadFile("testdata/parameters/groups/main.tf")
require.NoError(t, err)
+47 -21
View File
@@ -9,6 +9,7 @@ import (
"golang.org/x/xerrors"
"cdr.dev/slog/v3"
agpl "github.com/coder/coder/v2/coderd"
"github.com/coder/coder/v2/coderd/audit"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/db2sdk"
@@ -17,6 +18,7 @@ import (
"github.com/coder/coder/v2/coderd/httpmw"
"github.com/coder/coder/v2/coderd/rbac/acl"
"github.com/coder/coder/v2/coderd/rbac/policy"
"github.com/coder/coder/v2/coderd/searchquery"
"github.com/coder/coder/v2/coderd/util/slice"
"github.com/coder/coder/v2/codersdk"
)
@@ -50,39 +52,63 @@ func (api *API) templateAvailablePermissions(rw http.ResponseWriter, r *http.Req
return
}
// Apply the same q/limit semantics to groups as the users half of this response.
// The query semantics are defined for the users, which is awkward. But we can
// just reuse the search part of the query which is a fuzzy match.
userFilter, verr := searchquery.Users(r.URL.Query().Get("q"))
if len(verr) > 0 {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid user search query.",
Validations: verr,
})
return
}
groupPagination, ok := agpl.ParsePagination(rw, r)
if !ok {
return
}
// Perm check is the template update check.
// nolint:gocritic
groups, err := api.Database.GetGroups(dbauthz.AsSystemRestricted(ctx), database.GetGroupsParams{
OrganizationID: template.OrganizationID,
Search: userFilter.Search,
// #nosec G115 - Pagination limits are small and fit in int32
LimitOpt: int32(groupPagination.Limit),
})
if err != nil {
httpapi.InternalServerError(rw, err)
return
}
// Fetch member counts for all groups in a single query to avoid an
// N+1 lookup pattern that was making this endpoint extremely slow on
// deployments with many groups. The per-group member lists are
// intentionally not populated here: callers of this endpoint only
// surface total_member_count (see Group.TotalMemberCount, which is
// already documented as the canonical value).
groupIDs := make([]uuid.UUID, len(groups))
for i, g := range groups {
groupIDs[i] = g.Group.ID
}
// nolint:gocritic // Same justification as the GetGroups call above.
countRows, err := api.Database.GetGroupMembersCountByGroupIDs(dbauthz.AsSystemRestricted(ctx), database.GetGroupMembersCountByGroupIDsParams{
GroupIds: groupIDs,
IncludeSystem: false,
})
if err != nil {
httpapi.InternalServerError(rw, err)
return
}
countByGroup := make(map[uuid.UUID]int64, len(countRows))
for _, row := range countRows {
countByGroup[row.GroupID] = row.MemberCount
}
sdkGroups := make([]codersdk.Group, 0, len(groups))
for _, group := range groups {
// nolint:gocritic
members, err := api.Database.GetGroupMembersByGroupID(dbauthz.AsSystemRestricted(ctx), database.GetGroupMembersByGroupIDParams{
GroupID: group.Group.ID,
IncludeSystem: false,
})
if err != nil {
httpapi.InternalServerError(rw, err)
return
}
// nolint:gocritic
memberCount, err := api.Database.GetGroupMembersCountByGroupID(dbauthz.AsSystemRestricted(ctx), database.GetGroupMembersCountByGroupIDParams{
GroupID: group.Group.ID,
IncludeSystem: false,
})
if err != nil {
httpapi.InternalServerError(rw, err)
return
}
sdkGroups = append(sdkGroups, db2sdk.Group(group, members, int(memberCount)))
sdkGroups = append(sdkGroups, db2sdk.Group(group, nil, int(countByGroup[group.Group.ID])))
}
httpapi.Write(ctx, rw, http.StatusOK, codersdk.ACLAvailable{
+114 -1
View File
@@ -1241,6 +1241,119 @@ func TestTemplateACL(t *testing.T) {
})
require.NoError(t, err)
})
// Regression test for PLAT-149. Previously this endpoint did an N+1
// fetch of every group's members and member count. Verify that the
// member count is returned correctly for many groups, and that the
// per-group members list is no longer populated (callers should rely
// on TotalMemberCount).
t.Run("AvailableReturnsGroupMemberCounts", func(t *testing.T) {
t.Parallel()
client, user := coderdenttest.New(t, &coderdenttest.Options{LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureTemplateRBAC: 1,
},
}})
admin, _ := coderdtest.CreateAnotherUser(t, client, user.OrganizationID, rbac.RoleTemplateAdmin(), rbac.RoleUserAdmin())
// Create a couple of users we can stuff into groups.
_, alice := coderdtest.CreateAnotherUser(t, client, user.OrganizationID)
_, bob := coderdtest.CreateAnotherUser(t, client, user.OrganizationID)
_, carol := coderdtest.CreateAnotherUser(t, client, user.OrganizationID)
// emptyGroup: zero non-system members.
// singleGroup: alice only.
// fullGroup: alice + bob + carol.
emptyGroup := coderdtest.CreateGroup(t, admin, user.OrganizationID, "empty-group")
singleGroup := coderdtest.CreateGroup(t, admin, user.OrganizationID, "single-group", alice)
fullGroup := coderdtest.CreateGroup(t, admin, user.OrganizationID, "full-group", alice, bob, carol)
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
ctx := testutil.Context(t, testutil.WaitLong)
available, err := admin.TemplateACLAvailable(ctx, template.ID, codersdk.UsersRequest{})
require.NoError(t, err)
wantCounts := map[uuid.UUID]int{
emptyGroup.ID: 0,
singleGroup.ID: 1,
fullGroup.ID: 3,
}
found := map[uuid.UUID]bool{}
for _, group := range available.Groups {
if want, ok := wantCounts[group.ID]; ok {
found[group.ID] = true
require.Equal(t, want, group.TotalMemberCount,
"unexpected total_member_count for group %q", group.Name)
require.Empty(t, group.Members,
"members must not be populated by the available endpoint for group %q", group.Name)
}
}
for id := range wantCounts {
require.True(t, found[id], "group %s missing from available response", id)
}
})
// Companion to the AvailableReturnsGroupMemberCounts test above. Verifies
// that the q query parameter applies a server-side substring filter on
// group name / display_name, and that limit caps the number of groups
// returned. The autocomplete sends both on each keystroke; before
// PLAT-149 both were ignored for groups.
t.Run("AvailableHonorsGroupSearchAndLimit", func(t *testing.T) {
t.Parallel()
client, user := coderdenttest.New(t, &coderdenttest.Options{LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureTemplateRBAC: 1,
},
}})
admin, _ := coderdtest.CreateAnotherUser(t, client, user.OrganizationID, rbac.RoleTemplateAdmin(), rbac.RoleUserAdmin())
// Create a handful of groups with predictable names so we can
// pin assertions to specific substrings.
engAlpha := coderdtest.CreateGroup(t, admin, user.OrganizationID, "engineering-alpha")
engBeta := coderdtest.CreateGroup(t, admin, user.OrganizationID, "engineering-beta")
design := coderdtest.CreateGroup(t, admin, user.OrganizationID, "design")
sales := coderdtest.CreateGroup(t, admin, user.OrganizationID, "sales")
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
ctx := testutil.Context(t, testutil.WaitLong)
groupIDs := func(available codersdk.ACLAvailable) []uuid.UUID {
ids := make([]uuid.UUID, 0, len(available.Groups))
for _, g := range available.Groups {
ids = append(ids, g.ID)
}
return ids
}
// q filters by group name / display_name substring.
filtered, err := admin.TemplateACLAvailable(ctx, template.ID, codersdk.UsersRequest{
SearchQuery: "engineering",
})
require.NoError(t, err)
got := groupIDs(filtered)
require.ElementsMatch(t, []uuid.UUID{engAlpha.ID, engBeta.ID}, got,
"q=engineering should return only engineering-* groups, got %v", got)
require.NotContains(t, got, design.ID)
require.NotContains(t, got, sales.ID)
// limit caps the number of groups returned. With 4 user-created
// groups plus the implicit Everyone group, asking for 2 must
// return at most 2 groups.
limited, err := admin.TemplateACLAvailable(ctx, template.ID, codersdk.UsersRequest{
Pagination: codersdk.Pagination{Limit: 2},
})
require.NoError(t, err)
require.Len(t, limited.Groups, 2,
"limit=2 should cap groups to 2, got %d", len(limited.Groups))
})
}
func TestUpdateTemplateACL(t *testing.T) {
@@ -1626,7 +1739,7 @@ func TestUpdateTemplateACL(t *testing.T) {
require.NoError(t, err)
// Should be able to see user 3
available, err := client2.TemplateACLAvailable(ctx, template.ID)
available, err := client2.TemplateACLAvailable(ctx, template.ID, codersdk.UsersRequest{})
require.NoError(t, err)
userFound := false
for _, avail := range available.Users {