Files
cc-backend/internal/graph/stats_cache.go
Jan Eitzinger bf48389aeb Optimize sortby in stats queries
Entire-Checkpoint: 9b5b833472e1
2026-03-20 05:39:22 +01:00

163 lines
5.1 KiB
Go

// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
// All rights reserved. This file is part of cc-backend.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package graph
import (
"context"
"fmt"
"slices"
"sync"
"github.com/ClusterCockpit/cc-backend/internal/graph/model"
)
// statsGroupCache is a per-request cache for grouped JobsStatistics results.
// It deduplicates identical (filter+groupBy) SQL queries that arise when the
// frontend requests multiple sort/page slices of the same underlying data
// (e.g. topUserJobs, topUserNodes, topUserAccs all group by USER).
type statsGroupCache struct {
mu sync.Mutex
entries map[string]*cacheEntry
}
type cacheEntry struct {
once sync.Once
result []*model.JobsStatistics
err error
}
type ctxKey int
const statsGroupCacheKey ctxKey = iota
// newStatsGroupCache creates a new empty cache.
func newStatsGroupCache() *statsGroupCache {
return &statsGroupCache{
entries: make(map[string]*cacheEntry),
}
}
// WithStatsGroupCache injects a new cache into the context.
func WithStatsGroupCache(ctx context.Context) context.Context {
return context.WithValue(ctx, statsGroupCacheKey, newStatsGroupCache())
}
// getStatsGroupCache retrieves the cache from context, or nil if absent.
func getStatsGroupCache(ctx context.Context) *statsGroupCache {
if c, ok := ctx.Value(statsGroupCacheKey).(*statsGroupCache); ok {
return c
}
return nil
}
// cacheKey builds a deterministic string key from filter + groupBy + reqFields.
func statsCacheKey(filter []*model.JobFilter, groupBy *model.Aggregate, reqFields map[string]bool) string {
return fmt.Sprintf("%v|%v|%v", filter, *groupBy, reqFields)
}
// getOrCompute returns cached results for the given key, computing them on
// first access via the provided function.
func (c *statsGroupCache) getOrCompute(
key string,
compute func() ([]*model.JobsStatistics, error),
) ([]*model.JobsStatistics, error) {
c.mu.Lock()
entry, ok := c.entries[key]
if !ok {
entry = &cacheEntry{}
c.entries[key] = entry
}
c.mu.Unlock()
entry.once.Do(func() {
entry.result, entry.err = compute()
})
return entry.result, entry.err
}
// sortAndPageStats sorts a copy of allStats by the given sortBy field (descending)
// and returns the requested page slice.
func sortAndPageStats(allStats []*model.JobsStatistics, sortBy *model.SortByAggregate, page *model.PageRequest) []*model.JobsStatistics {
// Work on a shallow copy so the cached slice order is not mutated.
sorted := make([]*model.JobsStatistics, len(allStats))
copy(sorted, allStats)
if sortBy != nil {
getter := statsFieldGetter(*sortBy)
slices.SortFunc(sorted, func(a, b *model.JobsStatistics) int {
return getter(b) - getter(a) // descending
})
}
if page != nil && page.ItemsPerPage != -1 {
start := (page.Page - 1) * page.ItemsPerPage
if start >= len(sorted) {
return nil
}
end := start + page.ItemsPerPage
if end > len(sorted) {
end = len(sorted)
}
sorted = sorted[start:end]
}
return sorted
}
// sortByFieldName maps a SortByAggregate enum to the corresponding reqFields key.
// This ensures the DB computes the column that sortAndPageStats will sort by.
func sortByFieldName(sortBy model.SortByAggregate) string {
switch sortBy {
case model.SortByAggregateTotaljobs:
return "totalJobs"
case model.SortByAggregateTotalusers:
return "totalUsers"
case model.SortByAggregateTotalwalltime:
return "totalWalltime"
case model.SortByAggregateTotalnodes:
return "totalNodes"
case model.SortByAggregateTotalnodehours:
return "totalNodeHours"
case model.SortByAggregateTotalcores:
return "totalCores"
case model.SortByAggregateTotalcorehours:
return "totalCoreHours"
case model.SortByAggregateTotalaccs:
return "totalAccs"
case model.SortByAggregateTotalacchours:
return "totalAccHours"
default:
return "totalJobs"
}
}
// statsFieldGetter returns a function that extracts the sortable int field
// from a JobsStatistics struct for the given sort key.
func statsFieldGetter(sortBy model.SortByAggregate) func(*model.JobsStatistics) int {
switch sortBy {
case model.SortByAggregateTotaljobs:
return func(s *model.JobsStatistics) int { return s.TotalJobs }
case model.SortByAggregateTotalusers:
return func(s *model.JobsStatistics) int { return s.TotalUsers }
case model.SortByAggregateTotalwalltime:
return func(s *model.JobsStatistics) int { return s.TotalWalltime }
case model.SortByAggregateTotalnodes:
return func(s *model.JobsStatistics) int { return s.TotalNodes }
case model.SortByAggregateTotalnodehours:
return func(s *model.JobsStatistics) int { return s.TotalNodeHours }
case model.SortByAggregateTotalcores:
return func(s *model.JobsStatistics) int { return s.TotalCores }
case model.SortByAggregateTotalcorehours:
return func(s *model.JobsStatistics) int { return s.TotalCoreHours }
case model.SortByAggregateTotalaccs:
return func(s *model.JobsStatistics) int { return s.TotalAccs }
case model.SortByAggregateTotalacchours:
return func(s *model.JobsStatistics) int { return s.TotalAccHours }
default:
return func(s *model.JobsStatistics) int { return s.TotalJobs }
}
}