mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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)
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user