From 4fe128d77b0fdc96cf6dad32cfc77cf59ec4ad5b Mon Sep 17 00:00:00 2001 From: Slick Daddy <129640104+slick-daddy@users.noreply.github.com> Date: Thu, 3 Sep 2026 04:16:08 +0300 Subject: [PATCH 1/2] perf: batch user role loading --- internal/api/resolver_model_user.go | 8 +++++++- internal/dataloader/loaders.go | 9 +++++++++ internal/queries/querier.go | 1 + internal/queries/sql/user.sql | 3 +++ internal/queries/user.sql.go | 24 ++++++++++++++++++++++++ internal/service/user/service.go | 20 ++++++++++++++++++++ 6 files changed, 64 insertions(+), 1 deletion(-) diff --git a/internal/api/resolver_model_user.go b/internal/api/resolver_model_user.go index 2ff1d59de..dd4876d27 100644 --- a/internal/api/resolver_model_user.go +++ b/internal/api/resolver_model_user.go @@ -4,6 +4,7 @@ import ( "context" "github.com/stashapp/stash-box/internal/auth" + "github.com/stashapp/stash-box/internal/converter" "github.com/stashapp/stash-box/internal/dataloader" "github.com/stashapp/stash-box/internal/models" ) @@ -22,7 +23,12 @@ func (r *userResolver) Roles(ctx context.Context, user *models.User) ([]models.R } } - return r.services.User().GetRoles(ctx, user.ID) + roleStrings, err := dataloader.For(ctx).UserRolesByID.Load(user.ID) + if err != nil { + return nil, err + } + + return converter.StringsToRoleEnums(roleStrings), nil } func (r *userResolver) VoteCount(ctx context.Context, obj *models.User) (*models.UserVoteCount, error) { diff --git a/internal/dataloader/loaders.go b/internal/dataloader/loaders.go index d3d9b0743..b9bfad34c 100644 --- a/internal/dataloader/loaders.go +++ b/internal/dataloader/loaders.go @@ -51,6 +51,7 @@ type Loaders struct { SceneEditsByID EditsLoader EditCommentByID EditCommentLoader UserByID UserLoader + UserRolesByID StringsLoader } func Middleware(fac service.Factory) func(next http.Handler) http.Handler { @@ -299,6 +300,14 @@ func GetLoaders(ctx context.Context, fac service.Factory) *Loaders { return s.LoadIds(ctx, ids) }, }, + UserRolesByID: StringsLoader{ + maxBatch: 1000, + wait: 1 * time.Millisecond, + fetch: func(ids []uuid.UUID) ([][]string, []error) { + s := fac.User() + return s.LoadRoles(ctx, ids) + }, + }, SceneByID: SceneLoader{ maxBatch: 1000, wait: 1 * time.Millisecond, diff --git a/internal/queries/querier.go b/internal/queries/querier.go index aff8cff88..5340b8287 100644 --- a/internal/queries/querier.go +++ b/internal/queries/querier.go @@ -303,6 +303,7 @@ type Querier interface { GetTagCategoriesByIds(ctx context.Context, dollar_1 []uuid.UUID) ([]TagCategory, error) GetUserNotificationSubscriptions(ctx context.Context, userID uuid.UUID) ([]NotificationType, error) GetUserRoles(ctx context.Context, userID uuid.UUID) ([]string, error) + GetUserRolesByUserIDs(ctx context.Context, dollar_1 []uuid.UUID) ([]UserRole, error) GetUsers(ctx context.Context, dollar_1 []uuid.UUID) ([]User, error) InviteKeyUsed(ctx context.Context, id uuid.UUID) (*int, error) IsImageUnused(ctx context.Context, id uuid.UUID) (bool, error) diff --git a/internal/queries/sql/user.sql b/internal/queries/sql/user.sql index f3583dd3e..fc49897e4 100644 --- a/internal/queries/sql/user.sql +++ b/internal/queries/sql/user.sql @@ -65,6 +65,9 @@ DELETE FROM user_roles WHERE user_id = $1; -- name: GetUserRoles :many SELECT role FROM user_roles WHERE user_id = $1; +-- name: GetUserRolesByUserIDs :many +SELECT user_id, role FROM user_roles WHERE user_id = ANY($1::UUID[]); + -- name: CountVotesByType :many SELECT vote, COUNT(*) as count FROM edit_votes WHERE user_id = $1 GROUP BY vote; diff --git a/internal/queries/user.sql.go b/internal/queries/user.sql.go index e61da0a74..f5cda61a6 100644 --- a/internal/queries/user.sql.go +++ b/internal/queries/user.sql.go @@ -299,6 +299,30 @@ func (q *Queries) GetUserRoles(ctx context.Context, userID uuid.UUID) ([]string, return items, nil } +const getUserRolesByUserIDs = `-- name: GetUserRolesByUserIDs :many +SELECT user_id, role FROM user_roles WHERE user_id = ANY($1::UUID[]) +` + +func (q *Queries) GetUserRolesByUserIDs(ctx context.Context, dollar_1 []uuid.UUID) ([]UserRole, error) { + rows, err := q.db.Query(ctx, getUserRolesByUserIDs, dollar_1) + if err != nil { + return nil, err + } + defer rows.Close() + items := []UserRole{} + for rows.Next() { + var i UserRole + if err := rows.Scan(&i.UserID, &i.Role); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getUsers = `-- name: GetUsers :many SELECT id, name, password_hash, email, api_key, api_calls, last_api_call, created_at, updated_at, invited_by, invite_tokens FROM users WHERE id = ANY($1::UUID[]) ` diff --git a/internal/service/user/service.go b/internal/service/user/service.go index 505a60834..fc718cc5d 100644 --- a/internal/service/user/service.go +++ b/internal/service/user/service.go @@ -206,6 +206,26 @@ func (s *User) GetRoles(ctx context.Context, userID uuid.UUID) ([]models.RoleEnu return converter.StringsToRoleEnums(roleStrings), nil } +// LoadRoles fetches roles for multiple users in one query. +func (s *User) LoadRoles(ctx context.Context, userIDs []uuid.UUID) ([][]string, []error) { + rows, err := s.queries.GetUserRolesByUserIDs(ctx, userIDs) + if err != nil { + return nil, errutil.DuplicateError(err, len(userIDs)) + } + + roleMap := make(map[uuid.UUID][]string, len(userIDs)) + for _, row := range rows { + roleMap[row.UserID] = append(roleMap[row.UserID], row.Role) + } + + roles := make([][]string, len(userIDs)) + for i, userID := range userIDs { + roles[i] = roleMap[userID] + } + + return roles, make([]error, len(userIDs)) +} + // NewUser registers a new user. It returns the activation key only if // email verification is not required, otherwise it returns nil. func (s *User) NewUser(ctx context.Context, emailAddr string, inviteKey *uuid.UUID) (*uuid.UUID, error) { From c0e474eaa5190d5c50ae18186d30c8398115bae8 Mon Sep 17 00:00:00 2001 From: Slick Daddy <129640104+slick-daddy@users.noreply.github.com> Date: Thu, 3 Sep 2026 04:33:24 +0300 Subject: [PATCH 2/2] test: refresh loaders during role promotion polling --- internal/api/edit_integration_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/api/edit_integration_test.go b/internal/api/edit_integration_test.go index 311f65ce1..05f9c857f 100644 --- a/internal/api/edit_integration_test.go +++ b/internal/api/edit_integration_test.go @@ -106,6 +106,7 @@ func (s *editTestRunner) testVotePermissionsPromotion() { func (s *editTestRunner) verifyUserRolePromotion(user *models.User) { assert.Eventually(s.t, func() bool { + s.newRequest() roles, _ := s.resolver.User().Roles(s.ctx, user) for _, role := range roles { if role == models.RoleEnumVote {