Skip to content

Commit db03c63

Browse files
committed
Faster leaderboards.
1 parent 73ae5a9 commit db03c63

File tree

4 files changed

+146
-117
lines changed

4 files changed

+146
-117
lines changed

backend/internal/graph/api/helpers.go

Lines changed: 73 additions & 107 deletions
Original file line numberDiff line numberDiff line change
@@ -69,131 +69,98 @@ func resolveAchievedAt(ctx context.Context, r *Resolver, achievementID string) (
6969
return &scalars.DateTime{Time: *ts}, nil
7070
}
7171

72-
// computeLeaderboardTags computes tags for a leaderboard entry based on entity type and viewer context
72+
// viewerContext holds pre-loaded viewer data to avoid N+1 queries in tag computation
73+
type viewerContext struct {
74+
teamIDs map[string]bool // Viewer's team IDs in the current project
75+
superTeamIDs map[string]bool // Viewer's superteam IDs in the current project
76+
churchID string // Viewer's church ID
77+
}
78+
79+
// computeLeaderboardTags computes tags for a leaderboard entry based on entity type and viewer context.
80+
// This function does NO database lookups - all data is pre-loaded into viewerCtx.
81+
// Note: ADMIN and TEAM_LEAD tags have been removed from project/event leaderboards.
82+
// TEAM_LEAD is only shown on team member leaderboards (handled separately).
7383
func computeLeaderboardTags(
74-
ctx context.Context,
7584
entityType model.LeaderboardEntityType,
7685
entityID string,
77-
projectID string,
7886
currentUserID string,
79-
ldrs *loaders.Loaders,
80-
) ([]model.LeaderboardEntryTag, error) {
87+
viewerCtx *viewerContext,
88+
) []model.LeaderboardEntryTag {
8189
tags := []model.LeaderboardEntryTag{}
8290

8391
switch entityType {
8492
case model.LeaderboardEntityTypePersons:
85-
// Check ME tag
93+
// ME tag - simple ID comparison
8694
if entityID == currentUserID {
8795
tags = append(tags, model.LeaderboardEntryTagMe)
8896
}
8997

90-
// Check ADMIN tag
91-
userRoles, ok := ctx.Value(middleware.UserRolesKey).([]string)
92-
if ok {
93-
for _, role := range userRoles {
94-
if role == "superadmin" || role == "admin" || role == "project_admin" {
95-
// Check if the entity (person in leaderboard) has admin roles
96-
personRolesThunk := ldrs.RolesByUserLoader.Load(ctx, entityID)
97-
personRoles, err := personRolesThunk()
98-
if err == nil {
99-
for _, personRole := range personRoles {
100-
if personRole.Role == model.RoleTypeSuperadmin ||
101-
personRole.Role == model.RoleTypeAdmin ||
102-
personRole.Role == model.RoleTypeProjectAdmin {
103-
tags = append(tags, model.LeaderboardEntryTagAdmin)
104-
goto checkTeamLead // Break out of nested loops
105-
}
106-
}
107-
}
108-
}
109-
}
110-
}
111-
112-
checkTeamLead:
113-
// Check TEAM_LEAD tag
114-
// Load viewer's teams
115-
viewerTeamsThunk := ldrs.TeamsByUserLoader.Load(ctx, currentUserID)
116-
allViewerTeams, err := viewerTeamsThunk()
117-
if err != nil {
118-
return tags, fmt.Errorf("failed to load viewer teams: %w", err)
119-
}
120-
121-
// Filter teams by project and get team IDs
122-
viewerTeamIDs := make(map[string]bool)
123-
for _, team := range allViewerTeams {
124-
if team.ProjectID == projectID {
125-
viewerTeamIDs[team.ID] = true
126-
}
98+
case model.LeaderboardEntityTypeTeams:
99+
// ME tag - check if this is one of viewer's teams
100+
if viewerCtx.teamIDs[entityID] {
101+
tags = append(tags, model.LeaderboardEntryTagMe)
127102
}
128103

129-
// Load person's roles
130-
personRolesThunk := ldrs.RolesByUserLoader.Load(ctx, entityID)
131-
personRoles, err := personRolesThunk()
132-
if err != nil {
133-
return tags, fmt.Errorf("failed to load person roles: %w", err)
104+
case model.LeaderboardEntityTypeSuperteams:
105+
// ME tag - check if this is viewer's superteam
106+
if viewerCtx.superTeamIDs[entityID] {
107+
tags = append(tags, model.LeaderboardEntryTagMe)
134108
}
135109

136-
// Check if person is TEAM_LEAD of any of viewer's teams
137-
for _, role := range personRoles {
138-
if role.Role == model.RoleTypeTeamLead && role.Scope != nil {
139-
if role.Scope.Type == model.ScopeTypeTeam {
140-
if viewerTeamIDs[role.Scope.ID] {
141-
tags = append(tags, model.LeaderboardEntryTagTeamLead)
142-
break
143-
}
144-
}
145-
}
110+
case model.LeaderboardEntityTypeChurches:
111+
// ME tag - check if this is viewer's church
112+
if entityID == viewerCtx.churchID {
113+
tags = append(tags, model.LeaderboardEntryTagMe)
146114
}
115+
}
147116

148-
case model.LeaderboardEntityTypeTeams:
149-
// For teams, only check ME tag
150-
// Load viewer's teams
151-
viewerTeamsThunk := ldrs.TeamsByUserLoader.Load(ctx, currentUserID)
152-
allViewerTeams, err := viewerTeamsThunk()
153-
if err != nil {
154-
return tags, fmt.Errorf("failed to load viewer teams: %w", err)
155-
}
117+
return tags
118+
}
156119

157-
// Filter by project and check if entityID matches
158-
for _, team := range allViewerTeams {
159-
if team.ProjectID == projectID && team.ID == entityID {
160-
tags = append(tags, model.LeaderboardEntryTagMe)
161-
break
162-
}
163-
}
120+
// preloadViewerContext loads all viewer-specific data needed for tag computation.
121+
// This is called ONCE before iterating over leaderboard entries.
122+
func preloadViewerContext(
123+
ctx context.Context,
124+
currentUserID string,
125+
projectID string,
126+
entityType model.LeaderboardEntityType,
127+
ldrs *loaders.Loaders,
128+
) (*viewerContext, error) {
129+
viewerCtx := &viewerContext{
130+
teamIDs: make(map[string]bool),
131+
superTeamIDs: make(map[string]bool),
132+
}
164133

165-
case model.LeaderboardEntityTypeSuperteams:
166-
// For superteams, only check ME tag
167-
// Load viewer's teams
168-
viewerTeamsThunk := ldrs.TeamsByUserLoader.Load(ctx, currentUserID)
169-
allViewerTeams, err := viewerTeamsThunk()
134+
// Load viewer's teams for TEAMS and SUPERTEAMS entity types
135+
if entityType == model.LeaderboardEntityTypeTeams || entityType == model.LeaderboardEntityTypeSuperteams {
136+
teamsThunk := ldrs.TeamsByUserLoader.Load(ctx, currentUserID)
137+
teams, err := teamsThunk()
170138
if err != nil {
171-
return tags, fmt.Errorf("failed to load viewer teams: %w", err)
139+
return viewerCtx, fmt.Errorf("failed to load viewer teams: %w", err)
172140
}
173-
174-
// Filter by project and check if any team belongs to this superteam
175-
for _, team := range allViewerTeams {
176-
if team.ProjectID == projectID && team.SuperTeam != nil && team.SuperTeam.ID == entityID {
177-
tags = append(tags, model.LeaderboardEntryTagMe)
178-
break
141+
for _, team := range teams {
142+
if team.ProjectID == projectID {
143+
viewerCtx.teamIDs[team.ID] = true
144+
if team.SuperTeam != nil {
145+
viewerCtx.superTeamIDs[team.SuperTeam.ID] = true
146+
}
179147
}
180148
}
149+
}
181150

182-
case model.LeaderboardEntityTypeChurches:
183-
// For churches, only check ME tag
184-
// Load viewer's user data
185-
viewerThunk := ldrs.UserByIDLoader.Load(ctx, currentUserID)
186-
viewer, err := viewerThunk()
151+
// Load viewer's church for CHURCHES entity type
152+
if entityType == model.LeaderboardEntityTypeChurches {
153+
userThunk := ldrs.UserByIDLoader.Load(ctx, currentUserID)
154+
user, err := userThunk()
187155
if err != nil {
188-
return tags, fmt.Errorf("failed to load viewer: %w", err)
156+
return viewerCtx, fmt.Errorf("failed to load viewer: %w", err)
189157
}
190-
191-
if viewer.Church.ID == entityID {
192-
tags = append(tags, model.LeaderboardEntryTagMe)
158+
if user != nil && user.Church != nil {
159+
viewerCtx.churchID = user.Church.ID
193160
}
194161
}
195162

196-
return tags, nil
163+
return viewerCtx, nil
197164
}
198165

199166
// buildLeaderboardConnection builds a GraphQL connection from leaderboard entries
@@ -227,14 +194,17 @@ func buildLeaderboardConnection(
227194
entries = entries[:requestedLimit]
228195
}
229196

230-
// Build edges
197+
// Pre-load viewer context ONCE (outside the loop) to avoid N+1 queries
198+
viewerCtx, err := preloadViewerContext(ctx, currentUserID, projectID, entityType, ldrs)
199+
if err != nil {
200+
return nil, fmt.Errorf("failed to preload viewer context: %w", err)
201+
}
202+
203+
// Build edges - NO dataloader calls in this loop
231204
edges := make([]model.LeaderboardEdge, len(entries))
232205
for i, entry := range entries {
233-
// Compute tags based on entity type and viewer context
234-
tags, err := computeLeaderboardTags(ctx, entityType, entry.EntityID, projectID, currentUserID, ldrs)
235-
if err != nil {
236-
return nil, fmt.Errorf("failed to compute tags for entry %s: %w", entry.EntityID, err)
237-
}
206+
// Compute tags using pre-loaded viewer context (no DB lookups)
207+
tags := computeLeaderboardTags(entityType, entry.EntityID, currentUserID, viewerCtx)
238208

239209
edges[i] = model.LeaderboardEdge{
240210
Cursor: fmt.Sprintf("%d", entry.Rank),
@@ -266,14 +236,10 @@ func buildLeaderboardConnection(
266236
EndCursor: endCursor,
267237
}
268238

269-
// Build "me" entry
239+
// Build "me" entry using same pre-loaded context
270240
var me *model.LeaderboardEntry
271241
if meEntry != nil {
272-
// Compute tags for "me" entry
273-
meTags, err := computeLeaderboardTags(ctx, entityType, meEntry.EntityID, projectID, currentUserID, ldrs)
274-
if err != nil {
275-
return nil, fmt.Errorf("failed to compute tags for me entry: %w", err)
276-
}
242+
meTags := computeLeaderboardTags(entityType, meEntry.EntityID, currentUserID, viewerCtx)
277243

278244
me = &model.LeaderboardEntry{
279245
ID: meEntry.EntityID,

backend/internal/loaders/team_member_leaderboard.go

Lines changed: 64 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55

66
"github.com/bcc-media/wayfarer/internal/cache"
77
"github.com/bcc-media/wayfarer/internal/database"
8+
"github.com/bcc-media/wayfarer/internal/database/sqlc"
89
"github.com/bcc-media/wayfarer/internal/graph/api/model"
910
"github.com/bcc-media/wayfarer/internal/middleware"
1011
"github.com/graph-gophers/dataloader/v7"
@@ -93,8 +94,8 @@ func teamMemberLeaderboardBatchFunc(db *database.DB, c *cache.CacheWithRegistry)
9394
continue
9495
}
9596

96-
// Get or compute tags for this user
97-
tags := getOrComputeTags(c, teamID, currentUserID, entries)
97+
// Get or compute tags for this user (includes TEAM_LEAD tag)
98+
tags := getOrComputeTags(ctx, db, c, teamID, currentUserID, entries)
9899

99100
// Convert cached entries to model with tags
100101
modelEntries := make([]model.LeaderboardEntry, len(entries))
@@ -122,8 +123,9 @@ func teamMemberLeaderboardBatchFunc(db *database.DB, c *cache.CacheWithRegistry)
122123
}
123124
}
124125

125-
// getOrComputeTags retrieves or computes tags for a specific user viewing the leaderboard
126-
func getOrComputeTags(c *cache.CacheWithRegistry, teamID, currentUserID string, entries []cachedLeaderboardEntry) cachedLeaderboardTags {
126+
// getOrComputeTags retrieves or computes tags for a specific user viewing the leaderboard.
127+
// This includes ME tag and TEAM_LEAD tag (with batched role loading).
128+
func getOrComputeTags(ctx context.Context, db *database.DB, c *cache.CacheWithRegistry, teamID, currentUserID string, entries []cachedLeaderboardEntry) cachedLeaderboardTags {
127129
// Check tag cache for this user
128130
tagCacheKey := cache.TeamMemberLeaderboardTagsKey(teamID, currentUserID)
129131
if cached, ok := c.Get(tagCacheKey); ok {
@@ -132,13 +134,45 @@ func getOrComputeTags(c *cache.CacheWithRegistry, teamID, currentUserID string,
132134
}
133135
}
134136

137+
// Collect all entry IDs for batched role loading
138+
entryIDs := make([]string, len(entries))
139+
for i, entry := range entries {
140+
entryIDs[i] = entry.ID
141+
}
142+
143+
// Batch load roles for all entries in ONE query
144+
entryRoles := make(map[string][]*model.UserRole)
145+
if len(entryIDs) > 0 && db != nil {
146+
rows, err := db.Queries.GetAllRolesForUsers(ctx, entryIDs)
147+
if err == nil {
148+
// Group roles by user ID
149+
for _, row := range rows {
150+
role := convertToUserRole(row)
151+
entryRoles[row.UserID] = append(entryRoles[row.UserID], role)
152+
}
153+
}
154+
}
155+
135156
// Compute tags for this user
136157
tags := make(cachedLeaderboardTags)
137158
for _, entry := range entries {
138159
entryTags := []model.LeaderboardEntryTag{}
160+
161+
// ME tag - simple ID comparison
139162
if entry.ID == currentUserID {
140163
entryTags = append(entryTags, model.LeaderboardEntryTagMe)
141164
}
165+
166+
// TEAM_LEAD tag - check if entry is team lead of THIS team
167+
for _, role := range entryRoles[entry.ID] {
168+
if role.Role == model.RoleTypeTeamLead && role.Scope != nil {
169+
if role.Scope.Type == model.ScopeTypeTeam && role.Scope.ID == teamID {
170+
entryTags = append(entryTags, model.LeaderboardEntryTagTeamLead)
171+
break
172+
}
173+
}
174+
}
175+
142176
if len(entryTags) > 0 {
143177
tags[entry.ID] = entryTags
144178
}
@@ -149,3 +183,29 @@ func getOrComputeTags(c *cache.CacheWithRegistry, teamID, currentUserID string,
149183

150184
return tags
151185
}
186+
187+
// convertToUserRole converts a database role row to a model.UserRole
188+
func convertToUserRole(row *sqlc.GetAllRolesForUsersRow) *model.UserRole {
189+
role := &model.UserRole{
190+
ID: row.ID,
191+
Role: model.RoleType(row.Role),
192+
}
193+
194+
// Build scope if any scope fields are set
195+
if row.ChurchID != nil || row.ProjectID != nil || row.TeamID != nil {
196+
scope := &model.RoleScope{}
197+
if row.ChurchID != nil {
198+
scope.Type = model.ScopeTypeChurch
199+
scope.ID = *row.ChurchID
200+
} else if row.ProjectID != nil {
201+
scope.Type = model.ScopeTypeProject
202+
scope.ID = *row.ProjectID
203+
} else if row.TeamID != nil {
204+
scope.Type = model.ScopeTypeTeam
205+
scope.ID = *row.TeamID
206+
}
207+
role.Scope = scope
208+
}
209+
210+
return role
211+
}

backend/internal/loaders/team_member_leaderboard_test.go

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package loaders
22

33
import (
4+
"context"
45
"testing"
56
"time"
67

@@ -142,8 +143,8 @@ func TestGetOrComputeTags_CacheHit(t *testing.T) {
142143
c.Set(cacheKey, expectedTags)
143144
time.Sleep(10 * time.Millisecond)
144145

145-
// Call getOrComputeTags - should return cached tags
146-
tags := getOrComputeTags(c, teamID, userID, entries)
146+
// Call getOrComputeTags - should return cached tags (db not needed for cache hit)
147+
tags := getOrComputeTags(context.Background(), nil, c, teamID, userID, entries)
147148

148149
assert.Contains(t, tags, userID)
149150
assert.Equal(t, []model.LeaderboardEntryTag{model.LeaderboardEntryTagMe}, tags[userID])
@@ -163,13 +164,15 @@ func TestGetOrComputeTags_CacheMiss(t *testing.T) {
163164
}
164165

165166
// No pre-populated cache - should compute and cache
166-
tags := getOrComputeTags(c, teamID, userID, entries)
167+
// Note: passing nil for db means TEAM_LEAD tags won't be computed (requires role lookup)
168+
// This test verifies ME tag computation works correctly
169+
tags := getOrComputeTags(context.Background(), nil, c, teamID, userID, entries)
167170

168171
// User should have ME tag on their entry
169172
assert.Contains(t, tags, userID)
170173
assert.Equal(t, []model.LeaderboardEntryTag{model.LeaderboardEntryTagMe}, tags[userID])
171174

172-
// Other user's entry should not have any tags
175+
// Other user's entry should not have any tags (no ME tag, and no TEAM_LEAD without db)
173176
assert.NotContains(t, tags, otherUserID)
174177

175178
// Verify tags were cached

yaak-workspace/yaak.ev_GPdZ4AbXG6.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ model: environment
33
id: ev_GPdZ4AbXG6
44
workspaceId: wk_XKsmbMdFg7
55
createdAt: 2025-11-25T12:37:04.349121
6-
updatedAt: 2025-11-26T13:12:34.724084
6+
updatedAt: 2025-11-26T13:36:10.268264
77
name: Local
88
public: true
99
base: false
@@ -16,7 +16,7 @@ variables:
1616
id: PR8gW4seez
1717
- enabled: true
1818
name: TOKEN
19-
value: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiVVMwMUs5VzFZNFRBMEpIOVRGS1FRTVlGUTlGMCIsInVzZXJfcm9sZXMiOlsidXNlciIsImFkbWluIiwic3VwZXJhZG1pbiJdLCJpc3MiOiJ3YXlmYXJlciIsImV4cCI6MTc2NDI0OTE0NiwiaWF0IjoxNzY0MTYyNzQ2fQ.ajnOTx-nXt4_Vz1IFg-P1-QorgtTgY8X7EDmbdzTcqk
19+
value: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiVVMwMUs5Vlo4NjYzSko1NjFTUjE1Q0ZQU0tHWiIsInVzZXJfcm9sZXMiOlsidXNlciIsImFkbWluIiwic3VwZXJhZG1pbiJdLCJpc3MiOiJ3YXlmYXJlciIsImV4cCI6MTc2NDI1MDU2NCwiaWF0IjoxNzY0MTY0MTY0fQ.gLQMkxs9gxVTuOx-FQtRnRIz8XvMPzO8pdvOVulhD1k
2020
id: IrVp557pTp
2121
color: var(--success)
2222
sortPriority: 0.0

0 commit comments

Comments
 (0)