Implement the following plan:

# Plan: Fix Slow Project Stats Query

## Context

The `/monitoring/projects/` page is significantly slower than `/monitoring/users/` despite using the same code path (`List.root.svelte`, same GraphQL query, same SQL structure). Both call `JobsStatsGrouped` via the `jobsStatistics` resolver.

## Root Cause

`WithStatsGroupCache` is injected into **every** GraphQL request context via `AroundOperations` in `cmd/cc-backend/server.go:96`. This means the cache path in the resolver is **always** taken for grouped stats queries.

The cache path calls:
```go
return r.Repo.JobsStatsGrouped(ctx, filter, nil, nil, groupBy, nil)
```
with `nil` for `reqFields`, which forces **all** SQL columns to be computed — including:
```sql
COUNT(DISTINCT job.hpc_user) AS totalUsers
```

This is the performance bottleneck:

- **User grouping** (`GROUP BY hpc_user`): Each group has exactly 1 distinct `hpc_user` value → `COUNT(DISTINCT)` = trivially 1 per group → fast
- **Project grouping** (`GROUP BY project`): Each project can have 10s–100s of distinct users across thousands of jobs → SQLite must maintain a hash set per group → expensive

The `List.root.svelte` frontend query does **not** request `totalUsers` for either page. It only requests: `id, name, totalJobs, shortJobs, totalWalltime, totalCoreHours, totalAccHours`. So `COUNT(DISTINCT hpc_user)` is computed for no reason, but is only costly for project grouping.

**Why user page is fast**: `COUNT(DISTINCT hpc_user)` when grouping by `hpc_user` is trivially 1 per group — effectively O(1) per group.

**Why project page is slow**: `COUNT(DISTINCT hpc_user)` when grouping by `project` requires deduplicating all users across potentially thousands of jobs per project.

## What the Status Page Needs

`StatusDash.svelte` requests `totalUsers` for `groupBy: SUBCLUSTER` — so `COUNT(DISTINCT hpc_user)` IS needed there. The fix must not break this.

## Fix

Change the cache path to pass the actual `reqFields` (derived from the GraphQL selection set) instead of `nil`, and include `reqFields` in the cache key so that identical requests still share cached results.

### Files to Modify

1. **`internal/graph/schema.resolvers.go`** (line ~678–688)
   - Pass `reqFields` instead of `nil` to `JobsStatsGrouped` in the cache path

2. **`internal/graph/stats_cache.go`** (function `statsCacheKey`)
   - Include reqFields in the cache key so caching still deduplicates identical requests

### Changes

**`internal/graph/stats_cache.go`**:

Update `statsCacheKey` to accept and include reqFields:
```go
func statsCacheKey(filter []*model.JobFilter, groupBy *model.Aggregate, reqFields map[string]bool) string {
    return fmt.Sprintf("%v|%v|%v", filter, *groupBy, reqFields)
}
```

**`internal/graph/schema.resolvers.go`**:

Change the cache call from:
```go
key := statsCacheKey(filter, groupBy)
allStats, err = cache.getOrCompute(key, func() ([]*model.JobsStatistics, error) {
    return r.Repo.JobsStatsGrouped(ctx, filter, nil, nil, groupBy, nil)
})
```
To:
```go
key := statsCacheKey(filter, groupBy, reqFields)
allStats, err = cache.getOrCompute(key, func() ([]*model.JobsStatistics, error) {
    return r.Repo.JobsStatsGrouped(ctx, filter, nil, nil, groupBy, reqFields)
})
```

## Why This Works

- The project list query never requests `totalUsers` → `COUNT(DISTINCT hpc_user)` is skipped → fast query
- The user list query also never requests `totalUsers` → same benefit (marginal since the user `COUNT(DISTINCT)` is trivial)
- The status page requests `totalUsers` → `COUNT(DISTINCT hpc_user)` is computed → no regression
- Cache deduplication still works: if two aliases in the same request have the same filter+groupBy+reqFields, they share the cached result

## Critical Files

- `internal/graph/schema.resolvers.go` — cache invocation (lines ~678–688)
- `internal/graph/stats_cache.go` — `statsCacheKey` function (line 57–59)
- `internal/repository/stats.go` — `buildStatsQuery` (lines 136–229, `need()` helper)

## Verification

1. Build: `go build ./...`
2. Test: `go test ./internal/repository/... ./internal/graph/...`
3. Start server: `./cc-backend -server -dev -loglevel debug`
4. Load `/monitoring/projects/?cluster=alex&startTime=last30d` — check that it loads at similar speed to `/monitoring/users/`
5. Check backend logs for `Timer JobsStatsGrouped call:` — project query should now be much faster
6. Verify `/monitoring/status/` still shows correct `Active Users` counts per subcluster

If you need specific details from before exiting plan mode (like exact code snippets, error messages, or content you generated), read the full transcript at: /Users/jan/.claude/projects/-Users-jan-prg-ClusterCockpit-cc-backend/258fc5cb-f8b6-4d74-92a9-df4954615c17.jsonl


     1→// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
     2→// All rights reserved. This file is part of cc-backend.
     3→// Use of this source code is governed by a MIT-style
     4→// license that can be found in the LICENSE file.
     5→
     6→package graph
     7→
     8→import (
     9→	"context"
    10→	"fmt"
    11→	"slices"
    12→	"sync"
    13→
    14→	"github.com/ClusterCockpit/cc-backend/internal/graph/model"
    15→)
    16→
    17→// statsGroupCache is a per-request cache for grouped JobsStatistics results.
    18→// It deduplicates identical (filter+groupBy) SQL queries that arise when the
    19→// frontend requests multiple sort/page slices of the same underlying data
    20→// (e.g. topUserJobs, topUserNodes, topUserAccs all group by USER).
    21→type statsGroupCache struct {
    22→	mu      sync.Mutex
    23→	entries map[string]*cacheEntry
    24→}
    25→
    26→type cacheEntry struct {
    27→	once   sync.Once
    28→	result []*model.JobsStatistics
    29→	err    error
    30→}
    31→
    32→type ctxKey int
    33→
    34→const statsGroupCacheKey ctxKey = iota
    35→
    36→// newStatsGroupCache creates a new empty cache.
    37→func newStatsGroupCache() *statsGroupCache {
    38→	return &statsGroupCache{
    39→		entries: make(map[string]*cacheEntry),
    40→	}
    41→}
    42→
    43→// WithStatsGroupCache injects a new cache into the context.
    44→func WithStatsGroupCache(ctx context.Context) context.Context {
    45→	return context.WithValue(ctx, statsGroupCacheKey, newStatsGroupCache())
    46→}
    47→
    48→// getStatsGroupCache retrieves the cache from context, or nil if absent.
    49→func getStatsGroupCache(ctx context.Context) *statsGroupCache {
    50→	if c, ok := ctx.Value(statsGroupCacheKey).(*statsGroupCache); ok {
    51→		return c
    52→	}
    53→	return nil
    54→}
    55→
    56→// cacheKey builds a deterministic string key from filter + groupBy.
    57→func statsCacheKey(filter []*model.JobFilter, groupBy *model.Aggregate) string {
    58→	return fmt.Sprintf("%v|%v", filter, *groupBy)
    59→}
    60→
    61→// getOrCompute returns cached results for the given key, computing them on
    62→// first access via the provided function.
    63→func (c *statsGroupCache) getOrCompute(
    64→	key string,
    65→	compute func() ([]*model.JobsStatistics, error),
    66→) ([]*model.JobsStatistics, error) {
    67→	c.mu.Lock()
    68→	entry, ok := c.entries[key]
    69→	if !ok {
    70→		entry = &cacheEntry{}
    71→		c.entries[key] = entry
    72→	}
    73→	c.mu.Unlock()
    74→
    75→	entry.once.Do(func() {
    76→		entry.result, entry.err = compute()
    77→	})
    78→	return entry.result, entry.err
    79→}
    80→
    81→// sortAndPageStats sorts a copy of allStats by the given sortBy field (descending)
    82→// and returns the requested page slice.
    83→func sortAndPageStats(allStats []*model.JobsStatistics, sortBy *model.SortByAggregate, page *model.PageRequest) []*model.JobsStatistics {
    84→	// Work on a shallow copy so the cached slice order is not mutated.
    85→	sorted := make([]*model.JobsStatistics, len(allStats))
    86→	copy(sorted, allStats)
    87→
    88→	if sortBy != nil {
    89→		getter := statsFieldGetter(*sortBy)
    90→		slices.SortFunc(sorted, func(a, b *model.JobsStatistics) int {
    91→			return getter(b) - getter(a) // descending
    92→		})
    93→	}
    94→
    95→	if page != nil && page.ItemsPerPage != -1 {
    96→		start := (page.Page - 1) * page.ItemsPerPage
    97→		if start >= len(sorted) {
    98→			return nil
    99→		}
   100→		end := start + page.ItemsPerPage
   101→		if end > len(sorted) {
   102→			end = len(sorted)
   103→		}
   104→		sorted = sorted[start:end]
   105→	}
   106→
   107→	return sorted
   108→}
   109→
   110→// statsFieldGetter returns a function that extracts the sortable int field
   111→// from a JobsStatistics struct for the given sort key.
   112→func statsFieldGetter(sortBy model.SortByAggregate) func(*model.JobsStatistics) int {
   113→	switch sortBy {
   114→	case model.SortByAggregateTotaljobs:
   115→		return func(s *model.JobsStatistics) int { return s.TotalJobs }
   116→	case model.SortByAggregateTotalusers:
   117→		return func(s *model.JobsStatistics) int { return s.TotalUsers }
   118→	case model.SortByAggregateTotalwalltime:
   119→		return func(s *model.JobsStatistics) int { return s.TotalWalltime }
   120→	case model.SortByAggregateTotalnodes:
   121→		return func(s *model.JobsStatistics) int { return s.TotalNodes }
   122→	case model.SortByAggregateTotalnodehours:
   123→		return func(s *model.JobsStatistics) int { return s.TotalNodeHours }
   124→	case model.SortByAggregateTotalcores:
   125→		return func(s *model.JobsStatistics) int { return s.TotalCores }
   126→	case model.SortByAggregateTotalcorehours:
   127→		return func(s *model.JobsStatistics) int { return s.TotalCoreHours }
   128→	case model.SortByAggregateTotalaccs:
   129→		return func(s *model.JobsStatistics) int { return s.TotalAccs }
   130→	case model.SortByAggregateTotalacchours:
   131→		return func(s *model.JobsStatistics) int { return s.TotalAccHours }
   132→	default:
   133→		return func(s *model.JobsStatistics) int { return s.TotalJobs }
   134→	}
   135→}
   136→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

// 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.
func statsCacheKey(filter []*model.JobFilter, groupBy *model.Aggregate) string {
	return fmt.Sprintf("%v|%v", filter, *groupBy)
}

// 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
}

// 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 }
	}
}


   670→
   671→	if fetchedMainStats {
   672→		if groupBy == nil {
   673→			stats, err = r.Repo.JobsStats(ctx, filter, reqFields)
   674→		} else {
   675→			startGrouped := time.Now()
   676→			// Use request-scoped cache: multiple aliases with same (filter, groupBy)
   677→			// but different sortBy/page hit the DB only once.
   678→			if cache := getStatsGroupCache(ctx); cache != nil {
   679→				key := statsCacheKey(filter, groupBy)
   680→				var allStats []*model.JobsStatistics
   681→				allStats, err = cache.getOrCompute(key, func() ([]*model.JobsStatistics, error) {
   682→					return r.Repo.JobsStatsGrouped(ctx, filter, nil, nil, groupBy, nil)
   683→				})
   684→				if err == nil {
   685→					stats = sortAndPageStats(allStats, sortBy, page)
   686→				}
   687→			} else {
   688→				stats, err = r.Repo.JobsStatsGrouped(ctx, filter, page, sortBy, groupBy, reqFields)
   689→			}
   690→			cclog.Infof("Timer JobsStatsGrouped call: %s", time.Since(startGrouped))
   691→		}
   692→	} else {
   693→		stats = make([]*model.JobsStatistics, 0, 1)
   694→		stats = append(stats, &model.JobsStatistics{})
   695→	}
   696→
   697→	// runningJobs and shortJobs are already inlined in JobsStats/JobsStatsGrouped.
   698→	// Only run separate count queries if main stats were not fetched.
   699→	if !fetchedMainStats {

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

	if fetchedMainStats {
		if groupBy == nil {
			stats, err = r.Repo.JobsStats(ctx, filter, reqFields)
		} else {
			startGrouped := time.Now()
			// Use request-scoped cache: multiple aliases with same (filter, groupBy)
			// but different sortBy/page hit the DB only once.
			if cache := getStatsGroupCache(ctx); cache != nil {
				key := statsCacheKey(filter, groupBy)
				var allStats []*model.JobsStatistics
				allStats, err = cache.getOrCompute(key, func() ([]*model.JobsStatistics, error) {
					return r.Repo.JobsStatsGrouped(ctx, filter, nil, nil, groupBy, nil)
				})
				if err == nil {
					stats = sortAndPageStats(allStats, sortBy, page)
				}
			} else {
				stats, err = r.Repo.JobsStatsGrouped(ctx, filter, page, sortBy, groupBy, reqFields)
			}
			cclog.Infof("Timer JobsStatsGrouped call: %s", time.Since(startGrouped))
		}
	} else {
		stats = make([]*model.JobsStatistics, 0, 1)
		stats = append(stats, &model.JobsStatistics{})
	}

	// runningJobs and shortJobs are already inlined in JobsStats/JobsStatsGrouped.
	// Only run separate count queries if main stats were not fetched.
	if !fetchedMainStats {


Now I'll apply both changes:


Edit

// cacheKey builds a deterministic string key from filter + groupBy.
func statsCacheKey(filter []*model.JobFilter, groupBy *model.Aggregate) string {
	return fmt.Sprintf("%v|%v", filter, *groupBy)
}

// 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)
}


The file /Users/jan/prg/ClusterCockpit/cc-backend/internal/graph/stats_cache.go has been updated successfully.

// 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)
}

// 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.
func statsCacheKey(filter []*model.JobFilter, groupBy *model.Aggregate) string {
	return fmt.Sprintf("%v|%v", filter, *groupBy)
}

// 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
}

// 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 }
	}
}

-// cacheKey builds a deterministic string key from filter + groupBy.

-func statsCacheKey(filter []*model.JobFilter, groupBy *model.Aggregate) string {

-  return fmt.Sprintf("%v|%v", filter, *groupBy)

+// 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)


			key := statsCacheKey(filter, groupBy)
				var allStats []*model.JobsStatistics
				allStats, err = cache.getOrCompute(key, func() ([]*model.JobsStatistics, error) {
					return r.Repo.JobsStatsGrouped(ctx, filter, nil, nil, groupBy, nil)
				})

			key := statsCacheKey(filter, groupBy, reqFields)
				var allStats []*model.JobsStatistics
				allStats, err = cache.getOrCompute(key, func() ([]*model.JobsStatistics, error) {
					return r.Repo.JobsStatsGrouped(ctx, filter, nil, nil, groupBy, reqFields)
				})


The file /Users/jan/prg/ClusterCockpit/cc-backend/internal/graph/schema.resolvers.go has been updated successfully.

			key := statsCacheKey(filter, groupBy, reqFields)
				var allStats []*model.JobsStatistics
				allStats, err = cache.getOrCompute(key, func() ([]*model.JobsStatistics, error) {
					return r.Repo.JobsStatsGrouped(ctx, filter, nil, nil, groupBy, reqFields)
				})

package graph

// This file will be automatically regenerated based on the schema, any resolver
// implementations
// will be copied through when generating and any unknown code will be moved to the end.
// Code generated by github.com/99designs/gqlgen version v0.17.88

import (
	"context"
	"errors"
	"fmt"
	"math"
	"regexp"
	"slices"
	"strconv"
	"strings"
	"time"

	"github.com/ClusterCockpit/cc-backend/internal/config"
	"github.com/ClusterCockpit/cc-backend/internal/graph/generated"
	"github.com/ClusterCockpit/cc-backend/internal/graph/model"
	"github.com/ClusterCockpit/cc-backend/internal/metricdispatch"
	"github.com/ClusterCockpit/cc-backend/internal/repository"
	"github.com/ClusterCockpit/cc-backend/pkg/archive"
	cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
	ccunit "github.com/ClusterCockpit/cc-lib/v2/ccUnits"
	"github.com/ClusterCockpit/cc-lib/v2/schema"
)

// Partitions is the resolver for the partitions field.
func (r *clusterResolver) Partitions(ctx context.Context, obj *schema.Cluster) ([]string, error) {
	return r.Repo.Partitions(obj.Name)
}

// StartTime is the resolver for the startTime field.
func (r *jobResolver) StartTime(ctx context.Context, obj *schema.Job) (*time.Time, error) {
	timestamp := time.Unix(obj.StartTime, 0)
	return &timestamp, nil
}

// Tags is the resolver for the tags field.
func (r *jobResolver) Tags(ctx context.Context, obj *schema.Job) ([]*schema.Tag, error) {
	return r.Repo.GetTags(repository.GetUserFromContext(ctx), obj.ID)
}

// ConcurrentJobs is the resolver for the concurrentJobs field.
func (r *jobResolver) ConcurrentJobs(ctx context.Context, obj *schema.Job) (*model.JobLinkResultList, error) {
	// FIXME: Make the hardcoded duration configurable
	if obj.Shared != "none" && obj.Duration > 600 {
		return r.Repo.FindConcurrentJobs(ctx, obj)
	}

	return nil, nil
}

// Footprint is the resolver for the footprint field.
func (r *jobResolver) Footprint(ctx context.Context, obj *schema.Job) ([]*model.FootprintValue, error) {
	rawFootprint, err := r.Repo.FetchFootprint(obj)
	if err != nil {
		cclog.Warn("Error while fetching job footprint data")
		return nil, err
	}

	res := []*model.FootprintValue{}
	for name, value := range rawFootprint {

		parts := strings.Split(name, "_")
		statPart := parts[len(parts)-1]
		nameParts := parts[:len(parts)-1]

		res = append(res, &model.FootprintValue{
			Name:  strings.Join(nameParts, "_"),
			Stat:  statPart,
			Value: value,
		})
	}

	return res, err
}

// EnergyFootprint is the resolver for the energyFootprint field.
func (r *jobResolver) EnergyFootprint(ctx context.Context, obj *schema.Job) ([]*model.EnergyFootprintValue, error) {
	rawEnergyFootprint, err := r.Repo.FetchEnergyFootprint(obj)
	if err != nil {
		cclog.Warn("Error while fetching job energy footprint data")
		return nil, err
	}

	res := []*model.EnergyFootprintValue{}
	for name, value := range rawEnergyFootprint {
		// Suboptimal: Nearly hardcoded metric name expectations
		matchCPU := regexp.MustCompile(`cpu|Cpu|CPU`)
		matchAcc := regexp.MustCompile(`acc|Acc|ACC`)
		matchMem := regexp.MustCompile(`mem|Mem|MEM`)
		matchCore := regexp.MustCompile(`core|Core|CORE`)

		hwType := ""
		switch test := name; { // NOtice ';' for var declaration
		case matchCPU.MatchString(test):
			hwType = "CPU"
		case matchAcc.MatchString(test):
			hwType = "Accelerator"
		case matchMem.MatchString(test):
			hwType = "Memory"
		case matchCore.MatchString(test):
			hwType = "Core"
		default:
			hwType = "Other"
		}

		res = append(res, &model.EnergyFootprintValue{
			Hardware: hwType,
			Metric:   name,
			Value:    value,
		})
	}
	return res, err
}

// MetaData is the resolver for the metaData field.
func (r *jobResolver) MetaData(ctx context.Context, obj *schema.Job) (any, error) {
	return r.Repo.FetchMetadata(obj)
}

// UserData is the resolver for the userData field.
func (r *jobResolver) UserData(ctx context.Context, obj *schema.Job) (*model.User, error) {
	return repository.GetUserRepository().FetchUserInCtx(ctx, obj.User)
}

// Name is the resolver for the name field.
func (r *metricValueResolver) Name(ctx context.Context, obj *schema.MetricValue) (*string, error) {
	panic(fmt.Errorf("not implemented: Name - name"))
}

// CreateTag is the resolver for the createTag field.
func (r *mutationResolver) CreateTag(ctx context.Context, typeArg string, name string, scope string) (*schema.Tag, error) {
	user := repository.GetUserFromContext(ctx)
	if user == nil {
		return nil, fmt.Errorf("no user in context")
	}

	// Test Access: Admins && Admin Tag OR Support/Admin and Global Tag OR Everyone && Private Tag
	if user.HasRole(schema.RoleAdmin) && scope == "admin" ||
		user.HasAnyRole([]schema.Role{schema.RoleAdmin, schema.RoleSupport}) && scope == "global" ||
		user.Username == scope {
		// Create in DB
		id, err := r.Repo.CreateTag(typeArg, name, scope)
		if err != nil {
			cclog.Warn("Error while creating tag")
			return nil, err
		}
		return &schema.Tag{ID: id, Type: typeArg, Name: name, Scope: scope}, nil
	} else {
		cclog.Warnf("Not authorized to create tag with scope: %s", scope)
		return nil, fmt.Errorf("not authorized to create tag with scope: %s", scope)
	}
}

// DeleteTag is the resolver for the deleteTag field.
func (r *mutationResolver) DeleteTag(ctx context.Context, id string) (string, error) {
	// This Uses ID string <-> ID string, removeTagFromList uses []string <-> []int
	panic(fmt.Errorf("not implemented: DeleteTag - deleteTag"))
}

// AddTagsToJob is the resolver for the addTagsToJob field.
func (r *mutationResolver) AddTagsToJob(ctx context.Context, job string, tagIds []string) ([]*schema.Tag, error) {
	user := repository.GetUserFromContext(ctx)
	if user == nil {
		return nil, fmt.Errorf("no user in context")
	}

	jid, err := strconv.ParseInt(job, 10, 64)
	if err != nil {
		cclog.Warn("Error while adding tag to job")
		return nil, err
	}

	tags := []*schema.Tag{}
	for _, tagID := range tagIds {
		// Get ID
		tid, err := strconv.ParseInt(tagID, 10, 64)
		if err != nil {
			cclog.Warn("Error while parsing tag id")
			return nil, err
		}

		// Test Exists
		_, _, tscope, exists := r.Repo.TagInfo(tid)
		if !exists {
			cclog.Warnf("Tag does not exist (ID): %d", tid)
			return nil, fmt.Errorf("tag does not exist (ID): %d", tid)
		}

		// Test Access: Admins && Admin Tag OR Support/Admin and Global Tag OR Everyone && Private Tag
		if user.HasRole(schema.RoleAdmin) && tscope == "admin" ||
			user.HasAnyRole([]schema.Role{schema.RoleAdmin, schema.RoleSupport}) && tscope == "global" ||
			user.Username == tscope {
			// Add to Job
			if tags, err = r.Repo.AddTag(user, jid, tid); err != nil {
				cclog.Warn("Error while adding tag")
				return nil, err
			}
		} else {
			cclog.Warnf("Not authorized to add tag: %d", tid)
			return nil, fmt.Errorf("not authorized to add tag: %d", tid)
		}
	}

	return tags, nil
}

// RemoveTagsFromJob is the resolver for the removeTagsFromJob field.
func (r *mutationResolver) RemoveTagsFromJob(ctx context.Context, job string, tagIds []string) ([]*schema.Tag, error) {
	user := repository.GetUserFromContext(ctx)
	if user == nil {
		return nil, fmt.Errorf("no user in context")
	}

	jid, err := strconv.ParseInt(job, 10, 64)
	if err != nil {
		cclog.Warn("Error while parsing job id")
		return nil, err
	}

	tags := []*schema.Tag{}
	for _, tagID := range tagIds {
		// Get ID
		tid, err := strconv.ParseInt(tagID, 10, 64)
		if err != nil {
			cclog.Warn("Error while parsing tag id")
			return nil, err
		}

		// Test Exists
		_, _, tscope, exists := r.Repo.TagInfo(tid)
		if !exists {
			cclog.Warnf("Tag does not exist (ID): %d", tid)
			return nil, fmt.Errorf("tag does not exist (ID): %d", tid)
		}

		// Test Access: Admins && Admin Tag OR Support/Admin and Global Tag OR Everyone && Private Tag
		if user.HasRole(schema.RoleAdmin) && tscope == "admin" ||
			user.HasAnyRole([]schema.Role{schema.RoleAdmin, schema.RoleSupport}) && tscope == "global" ||
			user.Username == tscope {
			// Remove from Job
			if tags, err = r.Repo.RemoveTag(user, jid, tid); err != nil {
				cclog.Warn("Error while removing tag")
				return nil, err
			}
		} else {
			cclog.Warnf("Not authorized to remove tag: %d", tid)
			return nil, fmt.Errorf("not authorized to remove tag: %d", tid)
		}

	}

	return tags, nil
}

// RemoveTagFromList is the resolver for the removeTagFromList field.
func (r *mutationResolver) RemoveTagFromList(ctx context.Context, tagIds []string) ([]int, error) {
	// Needs Contextuser
	user := repository.GetUserFromContext(ctx)
	if user == nil {
		return nil, fmt.Errorf("no user in context")
	}

	tags := []int{}
	for _, tagID := range tagIds {
		// Get ID
		tid, err := strconv.ParseInt(tagID, 10, 64)
		if err != nil {
			cclog.Warn("Error while parsing tag id for removal")
			return nil, err
		}

		// Test Exists
		_, _, tscope, exists := r.Repo.TagInfo(tid)
		if !exists {
			cclog.Warnf("Tag does not exist (ID): %d", tid)
			return nil, fmt.Errorf("tag does not exist (ID): %d", tid)
		}

		// Test Access: Admins && Admin Tag OR Everyone && Private Tag
		if user.HasRole(schema.RoleAdmin) && (tscope == "global" || tscope == "admin") || user.Username == tscope {
			// Remove from DB
			if err = r.Repo.RemoveTagByID(tid); err != nil {
				cclog.Warn("Error while removing tag")
				return nil, err
			} else {
				tags = append(tags, int(tid))
			}
		} else {
			cclog.Warnf("Not authorized to remove tag: %d", tid)
			return nil, fmt.Errorf("not authorized to remove tag: %d", tid)
		}
	}
	return tags, nil
}

// UpdateConfiguration is the resolver for the updateConfiguration field.
func (r *mutationResolver) UpdateConfiguration(ctx context.Context, name string, value string) (*string, error) {
	if err := repository.GetUserCfgRepo().UpdateConfig(name, value, repository.GetUserFromContext(ctx)); err != nil {
		cclog.Warn("Error while updating user config")
		return nil, err
	}

	return nil, nil
}

// ID is the resolver for the id field.
func (r *nodeResolver) ID(ctx context.Context, obj *schema.Node) (string, error) {
	panic(fmt.Errorf("not implemented: ID - id"))
}

// SchedulerState is the resolver for the schedulerState field.
func (r *nodeResolver) SchedulerState(ctx context.Context, obj *schema.Node) (schema.SchedulerState, error) {
	if obj.NodeState != "" {
		return obj.NodeState, nil
	} else {
		return "", fmt.Errorf("resolver: no SchedulerState (NodeState) on node object")
	}
}

// HealthState is the resolver for the healthState field.
func (r *nodeResolver) HealthState(ctx context.Context, obj *schema.Node) (string, error) {
	if obj.HealthState != "" {
		return string(obj.HealthState), nil
	} else {
		return "", fmt.Errorf("resolver: no HealthState (NodeState) on node object")
	}
}

// MetaData is the resolver for the metaData field.
func (r *nodeResolver) MetaData(ctx context.Context, obj *schema.Node) (any, error) {
	if obj.MetaData != nil {
		return obj.MetaData, nil
	} else {
		cclog.Debug("resolver: no MetaData (NodeState) on node object")
		emptyMeta := make(map[string]string, 0)
		return emptyMeta, nil
	}
}

// HealthData is the resolver for the healthData field.
func (r *nodeResolver) HealthData(ctx context.Context, obj *schema.Node) (any, error) {
	if obj.HealthData != nil {
		return obj.HealthData, nil
	} else {
		cclog.Debug("resolver: no HealthData (NodeState) on node object")
		emptyHealth := make(map[string][]string, 0)
		return emptyHealth, nil
	}
}

// Clusters is the resolver for the clusters field.
func (r *queryResolver) Clusters(ctx context.Context) ([]*schema.Cluster, error) {
	return archive.Clusters, nil
}

// Tags is the resolver for the tags field.
func (r *queryResolver) Tags(ctx context.Context) ([]*schema.Tag, error) {
	return r.Repo.GetTags(repository.GetUserFromContext(ctx), nil)
}

// GlobalMetrics is the resolver for the globalMetrics field.
func (r *queryResolver) GlobalMetrics(ctx context.Context) ([]*schema.GlobalMetricListItem, error) {
	user := repository.GetUserFromContext(ctx)

	if user != nil {
		if user.HasRole(schema.RoleUser) || user.HasRole(schema.RoleManager) {
			return archive.GlobalUserMetricList, nil
		}
	}

	return archive.GlobalMetricList, nil
}

// User is the resolver for the user field.
func (r *queryResolver) User(ctx context.Context, username string) (*model.User, error) {
	return repository.GetUserRepository().FetchUserInCtx(ctx, username)
}

// AllocatedNodes is the resolver for the allocatedNodes field.
func (r *queryResolver) AllocatedNodes(ctx context.Context, cluster string) ([]*model.Count, error) {
	data, err := r.Repo.AllocatedNodes(cluster)
	if err != nil {
		cclog.Warn("Error while fetching allocated nodes")
		return nil, err
	}

	counts := make([]*model.Count, 0, len(data))
	for subcluster, hosts := range data {
		counts = append(counts, &model.Count{
			Name:  subcluster,
			Count: len(hosts),
		})
	}

	return counts, nil
}

// Node is the resolver for the node field.
func (r *queryResolver) Node(ctx context.Context, id string) (*schema.Node, error) {
	repo := repository.GetNodeRepository()
	numericID, err := strconv.ParseInt(id, 10, 64)
	if err != nil {
		cclog.Warn("Error while parsing job id")
		return nil, err
	}
	return repo.GetNodeByID(numericID, false)
}

// Nodes is the resolver for the nodes field.
func (r *queryResolver) Nodes(ctx context.Context, filter []*model.NodeFilter, order *model.OrderByInput) (*model.NodeStateResultList, error) {
	repo := repository.GetNodeRepository()
	nodes, err := repo.QueryNodes(ctx, filter, nil, order) // Ignore Paging, Order Unused
	count := len(nodes)
	return &model.NodeStateResultList{Items: nodes, Count: &count}, err
}

// NodesWithMeta is the resolver for the nodesWithMeta field.
func (r *queryResolver) NodesWithMeta(ctx context.Context, filter []*model.NodeFilter, order *model.OrderByInput) (*model.NodeStateResultList, error) {
	// Why Extra Handler? -> graphql.CollectAllFields(ctx) only returns toplevel fields (i.e.: items, count), and not subfields like item.metaData
	repo := repository.GetNodeRepository()
	nodes, err := repo.QueryNodesWithMeta(ctx, filter, nil, order) // Ignore Paging, Order Unused
	count := len(nodes)
	return &model.NodeStateResultList{Items: nodes, Count: &count}, err
}

// NodeStates is the resolver for the nodeStates field.
func (r *queryResolver) NodeStates(ctx context.Context, filter []*model.NodeFilter) ([]*model.NodeStates, error) {
	repo := repository.GetNodeRepository()

	stateCounts, serr := repo.CountStates(ctx, filter, "node_state")
	if serr != nil {
		cclog.Warnf("Error while counting nodeStates: %s", serr.Error())
		return nil, serr
	}

	healthCounts, herr := repo.CountStates(ctx, filter, "health_state")
	if herr != nil {
		cclog.Warnf("Error while counting healthStates: %s", herr.Error())
		return nil, herr
	}

	allCounts := append(stateCounts, healthCounts...)

	return allCounts, nil
}

// NodeStatesTimed is the resolver for the nodeStatesTimed field.
func (r *queryResolver) NodeStatesTimed(ctx context.Context, filter []*model.NodeFilter, typeArg string) ([]*model.NodeStatesTimed, error) {
	repo := repository.GetNodeRepository()

	if typeArg == "node" {
		stateCounts, serr := repo.CountStatesTimed(ctx, filter, "node_state")
		if serr != nil {
			cclog.Warnf("Error while counting nodeStates in time: %s", serr.Error())
			return nil, serr
		}
		return stateCounts, nil
	}

	if typeArg == "health" {
		healthCounts, herr := repo.CountStatesTimed(ctx, filter, "health_state")
		if herr != nil {
			cclog.Warnf("Error while counting healthStates in time: %s", herr.Error())
			return nil, herr
		}
		return healthCounts, nil
	}

	return nil, errors.New("unknown Node State Query Type")
}

// Job is the resolver for the job field.
func (r *queryResolver) Job(ctx context.Context, id string) (*schema.Job, error) {
	numericID, err := strconv.ParseInt(id, 10, 64)
	if err != nil {
		cclog.Warn("Error while parsing job id")
		return nil, err
	}

	job, err := r.Repo.FindByID(ctx, numericID)
	if err != nil {
		cclog.Warn("Error while finding job by id")
		return nil, err
	}

	if user := repository.GetUserFromContext(ctx); user != nil &&
		job.User != user.Username &&
		user.HasNotRoles([]schema.Role{schema.RoleAdmin, schema.RoleSupport, schema.RoleManager}) {
		return nil, errors.New("you are not allowed to see this job")
	}

	return job, nil
}

// JobMetrics is the resolver for the jobMetrics field.
func (r *queryResolver) JobMetrics(ctx context.Context, id string, metrics []string, scopes []schema.MetricScope, resolution *int) ([]*model.JobMetricWithName, error) {
	if resolution == nil { // Load from Config
		if config.Keys.EnableResampling != nil {
			defaultRes := slices.Max(config.Keys.EnableResampling.Resolutions)
			resolution = &defaultRes
		} else { // Set 0 (Loads configured metric timestep)
			defaultRes := 0
			resolution = &defaultRes
		}
	}

	job, err := r.Query().Job(ctx, id)
	if err != nil {
		cclog.Warn("Error while querying job for metrics")
		return nil, err
	}

	data, err := metricdispatch.LoadData(job, metrics, scopes, ctx, *resolution)
	if err != nil {
		cclog.Warn("Error while loading job data")
		return nil, err
	}

	res := []*model.JobMetricWithName{}
	for name, md := range data {
		for scope, metric := range md {
			res = append(res, &model.JobMetricWithName{
				Name:   name,
				Scope:  scope,
				Metric: metric,
			})
		}
	}

	return res, err
}

// JobStats is the resolver for the jobStats field.
func (r *queryResolver) JobStats(ctx context.Context, id string, metrics []string) ([]*model.NamedStats, error) {
	job, err := r.Query().Job(ctx, id)
	if err != nil {
		cclog.Warnf("Error while querying job %s for metadata", id)
		return nil, err
	}

	data, err := metricdispatch.LoadJobStats(job, metrics, ctx)
	if err != nil {
		cclog.Warnf("Error while loading jobStats data for job id %s", id)
		return nil, err
	}

	res := []*model.NamedStats{}
	for name, md := range data {
		res = append(res, &model.NamedStats{
			Name: name,
			Data: &md,
		})
	}

	return res, err
}

// ScopedJobStats is the resolver for the scopedJobStats field.
func (r *queryResolver) ScopedJobStats(ctx context.Context, id string, metrics []string, scopes []schema.MetricScope) ([]*model.NamedStatsWithScope, error) {
	job, err := r.Query().Job(ctx, id)
	if err != nil {
		cclog.Warnf("Error while querying job %s for metadata", id)
		return nil, err
	}

	data, err := metricdispatch.LoadScopedJobStats(job, metrics, scopes, ctx)
	if err != nil {
		cclog.Warnf("Error while loading scopedJobStats data for job id %s", id)
		return nil, err
	}

	res := make([]*model.NamedStatsWithScope, 0)
	for name, scoped := range data {
		for scope, stats := range scoped {

			mdlStats := make([]*model.ScopedStats, 0)
			for _, stat := range stats {
				mdlStats = append(mdlStats, &model.ScopedStats{
					Hostname: stat.Hostname,
					ID:       stat.ID,
					Data:     stat.Data,
				})
			}

			res = append(res, &model.NamedStatsWithScope{
				Name:  name,
				Scope: scope,
				Stats: mdlStats,
			})
		}
	}

	return res, nil
}

// Jobs is the resolver for the jobs field.
func (r *queryResolver) Jobs(ctx context.Context, filter []*model.JobFilter, page *model.PageRequest, order *model.OrderByInput) (*model.JobResultList, error) {
	if page == nil {
		page = &model.PageRequest{
			ItemsPerPage: 50,
			Page:         1,
		}
	}

	jobs, err := r.Repo.QueryJobs(ctx, filter, page, order)
	if err != nil {
		cclog.Warn("Error while querying jobs")
		return nil, err
	}

	count, err := r.Repo.CountJobs(ctx, filter)
	if err != nil {
		cclog.Warn("Error while counting jobs")
		return nil, err
	}

	// Note: Even if App-Default 'config.Keys.UiDefaults["job_list_usePaging"]' is set, always return hasNextPage boolean.
	// Users can decide in frontend to use continuous scroll, even if app-default is paging!
	// Skip if page.ItemsPerPage == -1 ("Load All" -> No Next Page required, Status Dashboards)
	/*
	  Example Page 4 @ 10 IpP : Does item 41 exist?
	  Minimal Page 41 @ 1 IpP : If len(result) is 1, Page 5 @ 10 IpP exists.
	*/
	hasNextPage := false
	if page.ItemsPerPage != -1 {
		nextPage := &model.PageRequest{
			ItemsPerPage: 1,
			Page:         ((page.Page * page.ItemsPerPage) + 1),
		}
		nextJobs, err := r.Repo.QueryJobs(ctx, filter, nextPage, order)
		if err != nil {
			cclog.Warn("Error while querying next jobs")
			return nil, err
		}
		hasNextPage = len(nextJobs) == 1
	}

	return &model.JobResultList{Items: jobs, Count: &count, HasNextPage: &hasNextPage}, nil
}

// JobsStatistics is the resolver for the jobsStatistics field.
func (r *queryResolver) JobsStatistics(ctx context.Context, filter []*model.JobFilter, metrics []string, page *model.PageRequest, sortBy *model.SortByAggregate, groupBy *model.Aggregate, numDurationBins *string, numMetricBins *int) ([]*model.JobsStatistics, error) {
	var err error
	var stats []*model.JobsStatistics

	// Top Level Defaults
	defaultDurationBins := "1h"
	defaultMetricBins := 10

	// Build requested fields map for selective column computation
	statsFields := []string{
		"totalJobs", "totalUsers", "totalWalltime", "totalNodes", "totalCores",
		"totalAccs", "totalNodeHours", "totalCoreHours", "totalAccHours", "runningJobs", "shortJobs",
	}
	reqFields := make(map[string]bool, len(statsFields))
	fetchedMainStats := false
	for _, f := range statsFields {
		if requireField(ctx, f) {
			reqFields[f] = true
			if f != "runningJobs" && f != "shortJobs" {
				fetchedMainStats = true
			}
		}
	}

	if fetchedMainStats {
		if groupBy == nil {
			stats, err = r.Repo.JobsStats(ctx, filter, reqFields)
		} else {
			startGrouped := time.Now()
			// Use request-scoped cache: multiple aliases with same (filter, groupBy)
			// but different sortBy/page hit the DB only once.
			if cache := getStatsGroupCache(ctx); cache != nil {
				key := statsCacheKey(filter, groupBy)
				var allStats []*model.JobsStatistics
				allStats, err = cache.getOrCompute(key, func() ([]*model.JobsStatistics, error) {
					return r.Repo.JobsStatsGrouped(ctx, filter, nil, nil, groupBy, nil)
				})
				if err == nil {
					stats = sortAndPageStats(allStats, sortBy, page)
				}
			} else {
				stats, err = r.Repo.JobsStatsGrouped(ctx, filter, page, sortBy, groupBy, reqFields)
			}
			cclog.Infof("Timer JobsStatsGrouped call: %s", time.Since(startGrouped))
		}
	} else {
		stats = make([]*model.JobsStatistics, 0, 1)
		stats = append(stats, &model.JobsStatistics{})
	}

	// runningJobs and shortJobs are already inlined in JobsStats/JobsStatsGrouped.
	// Only run separate count queries if main stats were not fetched.
	if !fetchedMainStats {
		if groupBy != nil {
			if requireField(ctx, "shortJobs") {
				stats, err = r.Repo.AddJobCountGrouped(ctx, filter, groupBy, stats, "short")
			}
			if requireField(ctx, "runningJobs") {
				stats, err = r.Repo.AddJobCountGrouped(ctx, filter, groupBy, stats, "running")
			}
		} else {
			if requireField(ctx, "shortJobs") {
				stats, err = r.Repo.AddJobCount(ctx, filter, stats, "short")
			}
			if requireField(ctx, "runningJobs") {
				stats, err = r.Repo.AddJobCount(ctx, filter, stats, "running")
			}
		}
	}

	if err != nil {
		return nil, err
	}

	if requireField(ctx, "histDuration") || requireField(ctx, "histNumNodes") || requireField(ctx, "histNumCores") || requireField(ctx, "histNumAccs") {

		if numDurationBins == nil {
			numDurationBins = &defaultDurationBins
		}

		if groupBy == nil {
			stats[0], err = r.Repo.AddHistograms(ctx, filter, stats[0], numDurationBins)
			if err != nil {
				return nil, err
			}
		} else {
			return nil, errors.New("histograms only implemented without groupBy argument")
		}
	}

	if requireField(ctx, "histMetrics") {

		if numMetricBins == nil {
			numMetricBins = &defaultMetricBins
		}

		if groupBy == nil {
			stats[0], err = r.Repo.AddMetricHistograms(ctx, filter, metrics, stats[0], numMetricBins)
			if err != nil {
				return nil, err
			}
		} else {
			return nil, errors.New("metric histograms only implemented without groupBy argument")
		}
	}

	return stats, nil
}

// JobsMetricStats is the resolver for the jobsMetricStats field.
func (r *queryResolver) JobsMetricStats(ctx context.Context, filter []*model.JobFilter, metrics []string) ([]*model.JobStats, error) {
	// No Paging, Fixed Order by StartTime ASC
	order := &model.OrderByInput{
		Field: "startTime",
		Type:  "col",
		Order: "ASC",
	}

	jobs, err := r.Repo.QueryJobs(ctx, filter, nil, order)
	if err != nil {
		cclog.Warn("Error while querying jobs for comparison")
		return nil, err
	}

	res := []*model.JobStats{}
	for _, job := range jobs {
		data, err := metricdispatch.LoadJobStats(job, metrics, ctx)
		if err != nil {
			cclog.Warnf("Error while loading comparison jobStats data for job id %d", job.JobID)
			continue
			// return nil, err
		}

		sres := []*model.NamedStats{}
		for name, md := range data {
			sres = append(sres, &model.NamedStats{
				Name: name,
				Data: &md,
			})
		}

		numThreadsInt := int(job.NumHWThreads)
		numAccsInt := int(job.NumAcc)
		res = append(res, &model.JobStats{
			ID:              int(*job.ID),
			JobID:           strconv.Itoa(int(job.JobID)),
			StartTime:       int(job.StartTime),
			Duration:        int(job.Duration),
			Cluster:         job.Cluster,
			SubCluster:      job.SubCluster,
			NumNodes:        int(job.NumNodes),
			NumHWThreads:    &numThreadsInt,
			NumAccelerators: &numAccsInt,
			Stats:           sres,
		})
	}
	return res, err
}

// JobsFootprints is the resolver for the jobsFootprints field.
func (r *queryResolver) JobsFootprints(ctx context.Context, filter []*model.JobFilter, metrics []string) (*model.Footprints, error) {
	// NOTE: Legacy Naming! This resolver is for normalized histograms in analysis view only - *Not* related to DB "footprint" column!
	return r.jobsFootprints(ctx, filter, metrics)
}

// RooflineHeatmap is the resolver for the rooflineHeatmap field.
func (r *queryResolver) RooflineHeatmap(ctx context.Context, filter []*model.JobFilter, rows int, cols int, minX float64, minY float64, maxX float64, maxY float64) ([][]float64, error) {
	return r.rooflineHeatmap(ctx, filter, rows, cols, minX, minY, maxX, maxY)
}

// NodeMetrics is the resolver for the nodeMetrics field.
func (r *queryResolver) NodeMetrics(ctx context.Context, cluster string, nodes []string, scopes []schema.MetricScope, metrics []string, from time.Time, to time.Time) ([]*model.NodeMetrics, error) {
	user := repository.GetUserFromContext(ctx)
	if user != nil && !user.HasAnyRole([]schema.Role{schema.RoleAdmin, schema.RoleSupport}) {
		return nil, errors.New("you need to be administrator or support staff for this query")
	}

	defaultMetrics := make([]string, 0)
	for _, mc := range archive.GetCluster(cluster).MetricConfig {
		defaultMetrics = append(defaultMetrics, mc.Name)
	}
	if metrics == nil {
		metrics = defaultMetrics
	} else {
		metrics = slices.DeleteFunc(metrics, func(metric string) bool {
			return !slices.Contains(defaultMetrics, metric) // Remove undefined metrics.
		})
	}

	data, err := metricdispatch.LoadNodeData(cluster, metrics, nodes, scopes, from, to, ctx)
	if err != nil {
		cclog.Warn("error while loading node data")
		return nil, err
	}

	nodeRepo := repository.GetNodeRepository()
	stateMap, _ := nodeRepo.MapNodes(cluster)

	nodeMetrics := make([]*model.NodeMetrics, 0, len(data))
	for hostname, metrics := range data {
		host := &model.NodeMetrics{
			Host:    hostname,
			State:   stateMap[hostname],
			Metrics: make([]*model.JobMetricWithName, 0, len(metrics)*len(scopes)),
		}
		host.SubCluster, err = archive.GetSubClusterByNode(cluster, hostname)
		if err != nil {
			cclog.Warnf("error in nodeMetrics resolver: %s", err)
		}

		for metric, scopedMetrics := range metrics {
			for _, scopedMetric := range scopedMetrics {
				host.Metrics = append(host.Metrics, &model.JobMetricWithName{
					Name:   metric,
					Scope:  schema.MetricScopeNode,
					Metric: scopedMetric,
				})
			}
		}

		nodeMetrics = append(nodeMetrics, host)
	}

	return nodeMetrics, nil
}

// NodeMetricsList is the resolver for the nodeMetricsList field.
func (r *queryResolver) NodeMetricsList(ctx context.Context, cluster string, subCluster string, stateFilter string, nodeFilter string, scopes []schema.MetricScope, metrics []string, from time.Time, to time.Time, page *model.PageRequest, resolution *int) (*model.NodesResultList, error) {
	if resolution == nil { // Load from Config
		if config.Keys.EnableResampling != nil {
			defaultRes := slices.Max(config.Keys.EnableResampling.Resolutions)
			resolution = &defaultRes
		} else { // Set 0 (Loads configured metric timestep)
			defaultRes := 0
			resolution = &defaultRes
		}
	}

	user := repository.GetUserFromContext(ctx)
	if user != nil && !user.HasAnyRole([]schema.Role{schema.RoleAdmin, schema.RoleSupport}) {
		return nil, errors.New("you need to be administrator or support staff for this query")
	}

	nodeRepo := repository.GetNodeRepository()
	// nodes -> array hostname
	nodes, stateMap, countNodes, hasNextPage, nerr := nodeRepo.GetNodesForList(ctx, cluster, subCluster, stateFilter, nodeFilter, page)
	if nerr != nil {
		return nil, errors.New("could not retrieve node list required for resolving NodeMetricsList")
	}

	if metrics == nil {
		for _, mc := range archive.GetCluster(cluster).MetricConfig {
			metrics = append(metrics, mc.Name)
		}
	}

	// data -> map hostname:jobdata
	data, err := metricdispatch.LoadNodeListData(cluster, subCluster, nodes, metrics, scopes, *resolution, from, to, ctx)
	if err != nil {
		cclog.Warn("error while loading node data (Resolver.NodeMetricsList")
		return nil, err
	}

	nodeMetricsList := make([]*model.NodeMetrics, 0, len(data))
	for _, hostname := range nodes {
		host := &model.NodeMetrics{
			Host:    hostname,
			State:   stateMap[hostname],
			Metrics: make([]*model.JobMetricWithName, 0),
		}
		host.SubCluster, err = archive.GetSubClusterByNode(cluster, hostname)
		if err != nil {
			cclog.Warnf("error in nodeMetrics resolver: %s", err)
		}

		for metric, scopedMetrics := range data[hostname] {
			for scope, scopedMetric := range scopedMetrics {
				host.Metrics = append(host.Metrics, &model.JobMetricWithName{
					Name:   metric,
					Scope:  scope,
					Metric: scopedMetric,
				})
			}
		}

		nodeMetricsList = append(nodeMetricsList, host)
	}

	nodeMetricsListResult := &model.NodesResultList{
		Items: nodeMetricsList,
		// TotalNodes depends on sum of nodes grouped on latest timestamp, see repo/node.go:357
		TotalNodes:  &countNodes,
		HasNextPage: &hasNextPage,
	}

	return nodeMetricsListResult, nil
}

// ClusterMetrics is the resolver for the clusterMetrics field.
func (r *queryResolver) ClusterMetrics(ctx context.Context, cluster string, metrics []string, from time.Time, to time.Time) (*model.ClusterMetrics, error) {
	user := repository.GetUserFromContext(ctx)
	if user != nil && !user.HasAnyRole([]schema.Role{schema.RoleAdmin, schema.RoleSupport}) {
		return nil, errors.New("you need to be administrator or support staff for this query")
	}

	if metrics == nil {
		for _, mc := range archive.GetCluster(cluster).MetricConfig {
			metrics = append(metrics, mc.Name)
		}
	}

	// 'nodes' == nil -> Defaults to all nodes of cluster for existing query workflow
	scopes := []schema.MetricScope{"node"}
	data, err := metricdispatch.LoadNodeData(cluster, metrics, nil, scopes, from, to, ctx)
	if err != nil {
		cclog.Warn("error while loading node data")
		return nil, err
	}

	clusterMetricData := make([]*model.ClusterMetricWithName, 0)
	clusterMetrics := model.ClusterMetrics{NodeCount: 0, Metrics: clusterMetricData}

	collectorTimestep := make(map[string]int)
	collectorUnit := make(map[string]schema.Unit)
	collectorData := make(map[string][]schema.Float)

	for _, metrics := range data {
		clusterMetrics.NodeCount += 1
		for metric, scopedMetrics := range metrics {
			for _, scopedMetric := range scopedMetrics {
				// Collect Info Once
				_, okTimestep := collectorTimestep[metric]
				if !okTimestep {
					collectorTimestep[metric] = scopedMetric.Timestep
				}
				_, okUnit := collectorUnit[metric]
				if !okUnit {
					collectorUnit[metric] = scopedMetric.Unit
				}
				// Collect Data
				for _, ser := range scopedMetric.Series {
					_, okData := collectorData[metric]
					// Init With Datasize > 0
					if !okData && len(ser.Data) != 0 {
						collectorData[metric] = make([]schema.Float, len(ser.Data))
					} else if !okData {
						cclog.Debugf("[SCHEMARESOLVER] clusterMetrics skip init: no data -> %s at %s; size %d", metric, ser.Hostname, len(ser.Data))
					}
					// Sum if init'd and matching size
					if okData && len(ser.Data) == len(collectorData[metric]) {
						for i, val := range ser.Data {
							if val.IsNaN() {
								continue
							} else {
								collectorData[metric][i] += val
							}
						}
					} else if okData {
						cclog.Debugf("[SCHEMARESOLVER] clusterMetrics skip sum: data diff -> %s at %s; want size %d, have size %d", metric, ser.Hostname, len(collectorData[metric]), len(ser.Data))
					}
				}
			}
		}
	}

	for metricName, data := range collectorData {
		// use ccUnits for backend normalization to "Tera"
		p_old := ccunit.NewPrefix(collectorUnit[metricName].Prefix)
		p_new := ccunit.NewPrefix("T")
		convFunc := ccunit.GetPrefixPrefixFactor(p_old, p_new)
		u_new := schema.Unit{Prefix: p_new.Prefix(), Base: collectorUnit[metricName].Base}

		roundedData := make([]schema.Float, 0)
		for _, v_old := range data {
			v_new := math.Round(convFunc(float64(v_old)).(float64)*100.0) / 100.0
			roundedData = append(roundedData, schema.Float(v_new))
		}

		cm := model.ClusterMetricWithName{
			Name:     metricName,
			Unit:     &u_new,
			Timestep: collectorTimestep[metricName],
			Data:     roundedData,
		}

		clusterMetrics.Metrics = append(clusterMetrics.Metrics, &cm)
	}

	return &clusterMetrics, nil
}

// NumberOfNodes is the resolver for the numberOfNodes field.
func (r *subClusterResolver) NumberOfNodes(ctx context.Context, obj *schema.SubCluster) (int, error) {
	nodeList, err := archive.ParseNodeList(obj.Nodes)
	if err != nil {
		return 0, err
	}
	return nodeList.NodeCount(), nil
}

// Cluster returns generated.ClusterResolver implementation.
func (r *Resolver) Cluster() generated.ClusterResolver { return &clusterResolver{r} }

// Job returns generated.JobResolver implementation.
func (r *Resolver) Job() generated.JobResolver { return &jobResolver{r} }

// MetricValue returns generated.MetricValueResolver implementation.
func (r *Resolver) MetricValue() generated.MetricValueResolver { return &metricValueResolver{r} }

// Mutation returns generated.MutationResolver implementation.
func (r *Resolver) Mutation() generated.MutationResolver { return &mutationResolver{r} }

// Node returns generated.NodeResolver implementation.
func (r *Resolver) Node() generated.NodeResolver { return &nodeResolver{r} }

// Query returns generated.QueryResolver implementation.
func (r *Resolver) Query() generated.QueryResolver { return &queryResolver{r} }

// SubCluster returns generated.SubClusterResolver implementation.
func (r *Resolver) SubCluster() generated.SubClusterResolver { return &subClusterResolver{r} }

type clusterResolver struct{ *Resolver }
type jobResolver struct{ *Resolver }
type metricValueResolver struct{ *Resolver }
type mutationResolver struct{ *Resolver }
type nodeResolver struct{ *Resolver }
type queryResolver struct{ *Resolver }
type subClusterResolver struct{ *Resolver }

-        key := statsCacheKey(filter, groupBy)

+        key := statsCacheKey(filter, groupBy, reqFields)

-          return r.Repo.JobsStatsGrouped(ctx, filter, nil, nil, groupBy, nil)

+          return r.Repo.JobsStatsGrouped(ctx, filter, nil, nil, groupBy, reqFields)


There's another call site. Let me find it:


679:				key := statsCacheKey(filter, groupBy, reqFields)


The diagnostic seems stale — there's only one call site and it already has the new signature. Let me verify the build:


Bash

Build to verify changes compile


(Bash completed with no output)


Clean build. Let me run the relevant tests:


go test ./internal/repository/... ./internal/graph/... 2>&1

Run repository and graph tests


ok  	github.com/ClusterCockpit/cc-backend/internal/repository	0.956s
?   	github.com/ClusterCockpit/cc-backend/internal/graph	[no test files]
?   	github.com/ClusterCockpit/cc-backend/internal/graph/generated	[no test files]
?   	github.com/ClusterCockpit/cc-backend/internal/graph/model	[no test files]


All tests pass. The two changes made:

1. **`stats_cache.go`**: `statsCacheKey` now accepts `reqFields map[string]bool` and includes it in the cache key, so requests with different field sets are cached separately.

2. **`schema.resolvers.go`**: The cache path now passes `reqFields` (instead of `nil`) to `JobsStatsGrouped`, so `COUNT(DISTINCT hpc_user)` is only computed when the frontend actually requests `totalUsers`.

**Effect**: The `/monitoring/projects/` page (which doesn't request `totalUsers`) will no longer trigger the expensive `COUNT(DISTINCT hpc_user)` per-project query. The `/monitoring/status/` page that does request `totalUsers` for subcluster grouping is unaffected.