From db625239eac21fb4fc3b979ff7677410aa8631e8 Mon Sep 17 00:00:00 2001 From: Christoph Kluge Date: Wed, 4 Mar 2026 14:18:30 +0100 Subject: [PATCH 1/8] apply updated rangefilter logic to energy and stats --- internal/routerConfig/routes.go | 58 +++++++++++++---- web/frontend/src/generic/Filters.svelte | 47 +++++++++++--- .../src/generic/filters/Energy.svelte | 62 +++++++++++++++---- .../src/generic/filters/InfoBox.svelte | 2 +- .../src/generic/filters/Resources.svelte | 4 +- web/frontend/src/generic/filters/Stats.svelte | 27 ++++++-- .../generic/select/DoubleRangeSlider.svelte | 6 +- web/frontend/src/generic/utils.js | 20 +++--- 8 files changed, 171 insertions(+), 55 deletions(-) diff --git a/internal/routerConfig/routes.go b/internal/routerConfig/routes.go index 218384e0..e24038e2 100644 --- a/internal/routerConfig/routes.go +++ b/internal/routerConfig/routes.go @@ -405,10 +405,22 @@ func buildFilterPresets(query url.Values) map[string]any { if query.Get("energy") != "" { parts := strings.Split(query.Get("energy"), "-") if len(parts) == 2 { - a, e1 := strconv.Atoi(parts[0]) - b, e2 := strconv.Atoi(parts[1]) - if e1 == nil && e2 == nil { - filterPresets["energy"] = map[string]int{"from": a, "to": b} + if parts[0] == "lessthan" { + lt, lte := strconv.Atoi(parts[1]) + if lte == nil { + filterPresets["energy"] = map[string]int{"from": 1, "to": lt} + } + } else if parts[0] == "morethan" { + mt, mte := strconv.Atoi(parts[1]) + if mte == nil { + filterPresets["energy"] = map[string]int{"from": mt, "to": 0} + } + } else { + a, e1 := strconv.Atoi(parts[0]) + b, e2 := strconv.Atoi(parts[1]) + if e1 == nil && e2 == nil { + filterPresets["energy"] = map[string]int{"from": a, "to": b} + } } } } @@ -417,15 +429,37 @@ func buildFilterPresets(query url.Values) map[string]any { for _, statEntry := range query["stat"] { parts := strings.Split(statEntry, "-") if len(parts) == 3 { // Metric Footprint Stat Field, from - to - a, e1 := strconv.ParseInt(parts[1], 10, 64) - b, e2 := strconv.ParseInt(parts[2], 10, 64) - if e1 == nil && e2 == nil { - statEntry := map[string]any{ - "field": parts[0], - "from": a, - "to": b, + if parts[1] == "lessthan" { + lt, lte := strconv.ParseInt(parts[2], 10, 64) + if lte == nil { + statEntry := map[string]any{ + "field": parts[0], + "from": 1, + "to": lt, + } + statList = append(statList, statEntry) + } + } else if parts[1] == "morethan" { + mt, mte := strconv.ParseInt(parts[2], 10, 64) + if mte == nil { + statEntry := map[string]any{ + "field": parts[0], + "from": mt, + "to": 0, + } + statList = append(statList, statEntry) + } + } else { + a, e1 := strconv.ParseInt(parts[1], 10, 64) + b, e2 := strconv.ParseInt(parts[2], 10, 64) + if e1 == nil && e2 == nil { + statEntry := map[string]any{ + "field": parts[0], + "from": a, + "to": b, + } + statList = append(statList, statEntry) } - statList = append(statList, statEntry) } } } diff --git a/web/frontend/src/generic/Filters.svelte b/web/frontend/src/generic/Filters.svelte index 162082c0..02f801a0 100644 --- a/web/frontend/src/generic/Filters.svelte +++ b/web/frontend/src/generic/Filters.svelte @@ -206,7 +206,7 @@ items.push({ duration: { to: filters.duration.lessThan, from: 0 } }); if (filters.duration.moreThan) items.push({ duration: { to: 0, from: filters.duration.moreThan } }); - if (filters.energy.from || filters.energy.to) + if (filters.energy.from != null || filters.energy.to != null) items.push({ energy: { from: filters.energy.from, to: filters.energy.to }, }); @@ -301,11 +301,20 @@ if (filters.node) opts.push(`node=${filters.node}`); if (filters.node && filters.nodeMatch != "eq") // "eq" is default-case opts.push(`nodeMatch=${filters.nodeMatch}`); - if (filters.energy.from && filters.energy.to) + if (filters.energy.from > 1 && filters.energy.to > 0) opts.push(`energy=${filters.energy.from}-${filters.energy.to}`); - if (filters.stats.length != 0) + else if (filters.energy.from > 1 && filters.energy.to == 0) + opts.push(`energy=morethan-${filters.energy.from}`); + else if (filters.energy.from == 1 && filters.energy.to > 0) + opts.push(`energy=lessthan-${filters.energy.to}`); + if (filters.stats.length > 0) for (let stat of filters.stats) { + if (stat.from > 1 && stat.to > 0) opts.push(`stat=${stat.field}-${stat.from}-${stat.to}`); + else if (stat.from > 1 && stat.to == 0) + opts.push(`stat=${stat.field}-morethan-${stat.from}`); + else if (stat.from == 1 && stat.to > 0) + opts.push(`stat=${stat.field}-lessthan-${stat.to}`); } // Build && Return if (opts.length == 0 && window.location.search.length <= 1) return; @@ -550,18 +559,36 @@ {/if} - {#if filters.energy.from || filters.energy.to} + {#if filters.energy.from > 1 && filters.energy.to > 0} (isEnergyOpen = true)}> - Total Energy: {filters.energy.from} - {filters.energy.to} + Total Energy: {filters.energy.from} - {filters.energy.to} kWh + + {:else if filters.energy.from > 1 && filters.energy.to == 0} + (isEnergyOpen = true)}> + Total Energy ≥ {filters.energy.from} kWh + + {:else if filters.energy.from == 1 && filters.energy.to > 0} + (isEnergyOpen = true)}> + Total Energy ≤ {filters.energy.to} kWh {/if} {#if filters.stats.length > 0} - (isStatsOpen = true)}> - {filters.stats - .map((stat) => `${stat.field}: ${stat.from} - ${stat.to}`) - .join(", ")} - + {#each filters.stats as stat} + {#if stat.from > 1 && stat.to > 0} + (isStatsOpen = true)}> + {stat.field}: {stat.from} - {stat.to} {stat.unit} +   + {:else if stat.from > 1 && stat.to == 0} + (isStatsOpen = true)}> + {stat.field} ≥ {stat.from} {stat.unit} +   + {:else if stat.from == 1 && stat.to > 0} + (isStatsOpen = true)}> + {stat.field} ≤ {stat.to} {stat.unit} +   + {/if} + {/each} {/if} {/if} diff --git a/web/frontend/src/generic/filters/Energy.svelte b/web/frontend/src/generic/filters/Energy.svelte index 4d542add..648fdb4d 100644 --- a/web/frontend/src/generic/filters/Energy.svelte +++ b/web/frontend/src/generic/filters/Energy.svelte @@ -15,54 +15,90 @@ ModalBody, ModalHeader, ModalFooter, + Tooltip, + Icon } from "@sveltestrap/sveltestrap"; import DoubleRangeSlider from "../select/DoubleRangeSlider.svelte"; /* Svelte 5 Props */ let { isOpen = $bindable(false), - presetEnergy = { - from: null, - to: null - }, + presetEnergy = { from: null, to: null }, setFilter, } = $props(); + /* Const */ + const minEnergyPreset = 1; + const maxEnergyPreset = 1000; + /* Derived */ - let energyState = $derived(presetEnergy); + // Pending + let pendingEnergyState = $derived({ + from: presetEnergy?.from ? presetEnergy.from : minEnergyPreset, + to: !(presetEnergy.to == null || presetEnergy.to == 0) ? presetEnergy.to : maxEnergyPreset, + }); + // Changable + let energyState = $derived({ + from: presetEnergy?.from ? presetEnergy.from : minEnergyPreset, + to: !(presetEnergy.to == null || presetEnergy.to == 0) ? presetEnergy.to : maxEnergyPreset, + }); + + const energyActive = $derived(!(JSON.stringify(energyState) === JSON.stringify({ from: minEnergyPreset, to: maxEnergyPreset }))); + // Block Apply if null + const disableApply = $derived(energyState.from === null || energyState.to === null); + + /* Function */ + function setEnergy() { + if (energyActive) { + pendingEnergyState = { + from: energyState.from, + to: (energyState.to == maxEnergyPreset) ? 0 : energyState.to + }; + } else { + pendingEnergyState = { from: null, to: null}; + }; + } (isOpen = !isOpen)}> Filter based on energy
-
Total Job Energy (kWh)
+
+ Total Job Energy (kWh) + +
+ + Generalized Presets. Use input fields to change to higher values. + { energyState.from = detail[0]; energyState.to = detail[1]; }} - sliderMin={0.0} - sliderMax={1000.0} - fromPreset={energyState?.from? energyState.from : 0.0} - toPreset={energyState?.to? energyState.to : 1000.0} + sliderMin={minEnergyPreset} + sliderMax={maxEnergyPreset} + fromPreset={energyState.from} + toPreset={energyState.to} />
diff --git a/web/frontend/src/generic/filters/InfoBox.svelte b/web/frontend/src/generic/filters/InfoBox.svelte index 0c249980..35af4635 100644 --- a/web/frontend/src/generic/filters/InfoBox.svelte +++ b/web/frontend/src/generic/filters/InfoBox.svelte @@ -20,7 +20,7 @@ } = $props(); - diff --git a/web/frontend/src/generic/select/DoubleRangeSlider.svelte b/web/frontend/src/generic/select/DoubleRangeSlider.svelte index c655087f..958db598 100644 --- a/web/frontend/src/generic/select/DoubleRangeSlider.svelte +++ b/web/frontend/src/generic/select/DoubleRangeSlider.svelte @@ -165,11 +165,11 @@ }} /> - {#if inputFieldFrom != "1" && inputFieldTo != sliderMax?.toString() } + {#if inputFieldFrom != sliderMin?.toString() && inputFieldTo != sliderMax?.toString() } Selected: Range {inputFieldFrom} - {inputFieldTo} - {:else if inputFieldFrom != "1" && inputFieldTo == sliderMax?.toString() } + {:else if inputFieldFrom != sliderMin?.toString() && inputFieldTo == sliderMax?.toString() } Selected: More than {inputFieldFrom} - {:else if inputFieldFrom == "1" && inputFieldTo != sliderMax?.toString() } + {:else if inputFieldFrom == sliderMin?.toString() && inputFieldTo != sliderMax?.toString() } Selected: Less than {inputFieldTo} {:else} No Selection diff --git a/web/frontend/src/generic/utils.js b/web/frontend/src/generic/utils.js index 09239ec8..82da21b8 100644 --- a/web/frontend/src/generic/utils.js +++ b/web/frontend/src/generic/utils.js @@ -341,26 +341,28 @@ export function getStatsItems(presetStats = []) { if (gm?.footprint) { const mc = getMetricConfigDeep(gm.name, null, null) if (mc) { - const presetEntry = presetStats.find((s) => s?.field === (gm.name + '_' + gm.footprint)) + const presetEntry = presetStats.find((s) => s.field == `${gm.name}_${gm.footprint}`) if (presetEntry) { return { - field: gm.name + '_' + gm.footprint, - text: gm.name + ' (' + gm.footprint + ')', + field: presetEntry.field, + text: `${gm.name} (${gm.footprint})`, metric: gm.name, from: presetEntry.from, - to: presetEntry.to, + to: (presetEntry.to == 0) ? mc.peak : presetEntry.to, peak: mc.peak, - enabled: true + enabled: true, + unit: `${gm?.unit?.prefix ? gm.unit.prefix : ''}${gm.unit.base}` } } else { return { - field: gm.name + '_' + gm.footprint, - text: gm.name + ' (' + gm.footprint + ')', + field: `${gm.name}_${gm.footprint}`, + text: `${gm.name} (${gm.footprint})`, metric: gm.name, - from: 0, + from: 1, to: mc.peak, peak: mc.peak, - enabled: false + enabled: false, + unit: `${gm?.unit?.prefix ? gm.unit.prefix : ''}${gm.unit.base}` } } } From cc0403e2a4f4c7cc0879d4004c7a141c8148dd1b Mon Sep 17 00:00:00 2001 From: Jan Eitzinger Date: Wed, 4 Mar 2026 14:32:40 +0100 Subject: [PATCH 2/8] Fix goreleaser config Entire-Checkpoint: a204a44fa885 --- .goreleaser.yaml | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 3edcb7d6..f861e3c2 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -1,3 +1,4 @@ +version: 2 before: hooks: - go mod tidy @@ -34,6 +35,19 @@ builds: main: ./tools/archive-manager tags: - static_build + - env: + - CGO_ENABLED=0 + goos: + - linux + goarch: + - amd64 + goamd64: + - v3 + id: "archive-migration" + binary: archive-migration + main: ./tools/archive-migration + tags: + - static_build - env: - CGO_ENABLED=0 goos: @@ -48,7 +62,7 @@ builds: tags: - static_build archives: - - format: tar.gz + - formats: tar.gz # this name template makes the OS and Arch compatible with the results of uname. name_template: >- {{ .ProjectName }}_ @@ -59,7 +73,7 @@ archives: checksum: name_template: "checksums.txt" snapshot: - name_template: "{{ incpatch .Version }}-next" + version_template: "{{ incpatch .Version }}-next" changelog: sort: asc filters: @@ -87,7 +101,7 @@ changelog: release: draft: false footer: | - Supports job archive version 2 and database version 8. + Supports job archive version 3 and database version 10. Please check out the [Release Notes](https://github.com/ClusterCockpit/cc-backend/blob/master/ReleaseNotes.md) for further details on breaking changes. # vim: set ts=2 sw=2 tw=0 fo=cnqoj From 33ec755422ae2ea72bca982299c108a264958477 Mon Sep 17 00:00:00 2001 From: Jan Eitzinger Date: Wed, 4 Mar 2026 15:04:53 +0100 Subject: [PATCH 3/8] Fix typo in job high memory tagger --- configs/tagger/jobclasses/highMemoryUsage.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/configs/tagger/jobclasses/highMemoryUsage.json b/configs/tagger/jobclasses/highMemoryUsage.json index f241457d..878cd669 100644 --- a/configs/tagger/jobclasses/highMemoryUsage.json +++ b/configs/tagger/jobclasses/highMemoryUsage.json @@ -16,6 +16,6 @@ "expr": "mem_used.max / mem_used.limits.peak * 100.0" } ], - "rule": "mem_used.max > memory_used.limits.alert", + "rule": "mem_used.max > mem_used.limits.alert", "hint": "This job used high memory: peak memory usage {{.mem_used.max}} GB ({{.memory_usage_pct}}% of {{.mem_used.limits.peak}} GB node capacity), exceeding the {{.highmemoryusage_threshold_factor}} utilization threshold. Risk of out-of-memory conditions." } From 67a17b530690239e7d00f8149aa20f78c37a0db5 Mon Sep 17 00:00:00 2001 From: Jan Eitzinger Date: Wed, 4 Mar 2026 15:14:35 +0100 Subject: [PATCH 4/8] Reduce noise in info log --- internal/tagger/classifyJob.go | 9 +++------ internal/tagger/detectApp.go | 10 +++++----- pkg/metricstore/healthcheck.go | 4 ++-- 3 files changed, 10 insertions(+), 13 deletions(-) diff --git a/internal/tagger/classifyJob.go b/internal/tagger/classifyJob.go index f8751047..6a53fae8 100644 --- a/internal/tagger/classifyJob.go +++ b/internal/tagger/classifyJob.go @@ -309,7 +309,7 @@ func (t *JobClassTagger) Register() error { func (t *JobClassTagger) Match(job *schema.Job) { jobStats, err := t.getStatistics(job) metricsList := t.getMetricConfig(job.Cluster, job.SubCluster) - cclog.Infof("Enter match rule with %d rules for job %d", len(t.rules), job.JobID) + cclog.Debugf("Enter match rule with %d rules for job %d", len(t.rules), job.JobID) if err != nil { cclog.Errorf("job classification failed for job %d: %#v", job.JobID, err) return @@ -321,7 +321,7 @@ func (t *JobClassTagger) Match(job *schema.Job) { for tag, ri := range t.rules { env := make(map[string]any) maps.Copy(env, ri.env) - cclog.Infof("Try to match rule %s for job %d", tag, job.JobID) + cclog.Debugf("Try to match rule %s for job %d", tag, job.JobID) // Initialize environment env["job"] = map[string]any{ @@ -369,7 +369,7 @@ func (t *JobClassTagger) Match(job *schema.Job) { break } if !ok.(bool) { - cclog.Infof("requirement for rule %s not met", tag) + cclog.Debugf("requirement for rule %s not met", tag) requirementsMet = false break } @@ -399,7 +399,6 @@ func (t *JobClassTagger) Match(job *schema.Job) { continue } if match.(bool) { - cclog.Info("Rule matches!") if !t.repo.HasTag(id, t.tagType, tag) { if _, err := t.repo.AddTagOrCreateDirect(id, t.tagType, tag); err != nil { cclog.Errorf("failed to add tag '%s' to job %d: %v", tag, id, err) @@ -414,8 +413,6 @@ func (t *JobClassTagger) Match(job *schema.Job) { continue } messages = append(messages, msg.String()) - } else { - cclog.Info("Rule does not match!") } } diff --git a/internal/tagger/detectApp.go b/internal/tagger/detectApp.go index 54626eff..97b9d6b0 100644 --- a/internal/tagger/detectApp.go +++ b/internal/tagger/detectApp.go @@ -178,24 +178,24 @@ func (t *AppTagger) Match(job *schema.Job) { metadata, err := r.FetchMetadata(job) if err != nil { - cclog.Infof("AppTagger: cannot fetch metadata for job %d on %s: %v", job.JobID, job.Cluster, err) + cclog.Debugf("AppTagger: cannot fetch metadata for job %d on %s: %v", job.JobID, job.Cluster, err) return } if metadata == nil { - cclog.Infof("AppTagger: metadata is nil for job %d on %s", job.JobID, job.Cluster) + cclog.Debugf("AppTagger: metadata is nil for job %d on %s", job.JobID, job.Cluster) return } jobscript, ok := metadata["jobScript"] if !ok { - cclog.Infof("AppTagger: no 'jobScript' key in metadata for job %d on %s (keys: %v)", + cclog.Debugf("AppTagger: no 'jobScript' key in metadata for job %d on %s (keys: %v)", job.JobID, job.Cluster, metadataKeys(metadata)) return } if len(jobscript) == 0 { - cclog.Infof("AppTagger: empty jobScript for job %d on %s", job.JobID, job.Cluster) + cclog.Debugf("AppTagger: empty jobScript for job %d on %s", job.JobID, job.Cluster) return } @@ -210,7 +210,7 @@ func (t *AppTagger) Match(job *schema.Job) { if r.HasTag(id, t.tagType, a.tag) { cclog.Debugf("AppTagger: job %d already has tag %s:%s, skipping", id, t.tagType, a.tag) } else { - cclog.Infof("AppTagger: pattern '%s' matched for app '%s' on job %d", re.String(), a.tag, id) + cclog.Debugf("AppTagger: pattern '%s' matched for app '%s' on job %d", re.String(), a.tag, id) if _, err := r.AddTagOrCreateDirect(id, t.tagType, a.tag); err != nil { cclog.Errorf("AppTagger: failed to add tag '%s' to job %d: %v", a.tag, id, err) } diff --git a/pkg/metricstore/healthcheck.go b/pkg/metricstore/healthcheck.go index 73973ab0..b3470a14 100644 --- a/pkg/metricstore/healthcheck.go +++ b/pkg/metricstore/healthcheck.go @@ -166,10 +166,10 @@ func (m *MemoryStore) HealthCheck(cluster string, healthyCount := len(expectedMetrics) - degradedCount - missingCount if degradedCount > 0 { - cclog.ComponentInfo("metricstore", "HealthCheck: node ", hostname, "degraded metrics:", degradedList) + cclog.ComponentDebug("metricstore", "HealthCheck: node ", hostname, "degraded metrics:", degradedList) } if missingCount > 0 { - cclog.ComponentInfo("metricstore", "HealthCheck: node ", hostname, "missing metrics:", missingList) + cclog.ComponentDebug("metricstore", "HealthCheck: node ", hostname, "missing metrics:", missingList) } var state schema.MonitoringState From 9672903d416a65304173757948924b4bb0d2fb69 Mon Sep 17 00:00:00 2001 From: Christoph Kluge Date: Wed, 4 Mar 2026 15:54:08 +0100 Subject: [PATCH 5/8] fix panic caused by concurrent map writes --- pkg/archive/clusterConfig.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pkg/archive/clusterConfig.go b/pkg/archive/clusterConfig.go index 64851365..3e27e415 100644 --- a/pkg/archive/clusterConfig.go +++ b/pkg/archive/clusterConfig.go @@ -126,6 +126,9 @@ func initClusterConfig() error { if newMetric.Energy != "" { sc.EnergyFootprint = append(sc.EnergyFootprint, newMetric.Name) } + + // Init Topology Lookup Maps Once Per Subcluster + sc.Topology.InitTopologyMaps() } item := metricLookup[mc.Name] From 26982088c33ce8efdd8b5acf0b6e99f11cba3656 Mon Sep 17 00:00:00 2001 From: Jan Eitzinger Date: Wed, 4 Mar 2026 16:43:05 +0100 Subject: [PATCH 6/8] Consolidate code for external and internal ccms buildQueries function Entire-Checkpoint: fc3be444ef4c --- .../cc-metric-store-queries.go | 324 +---------- internal/metricstoreclient/cc-metric-store.go | 26 +- pkg/metricstore/metricstore.go | 2 +- pkg/metricstore/query.go | 543 ++---------------- pkg/metricstore/scopequery.go | 314 ++++++++++ pkg/metricstore/scopequery_test.go | 273 +++++++++ 6 files changed, 676 insertions(+), 806 deletions(-) create mode 100644 pkg/metricstore/scopequery.go create mode 100644 pkg/metricstore/scopequery_test.go diff --git a/internal/metricstoreclient/cc-metric-store-queries.go b/internal/metricstoreclient/cc-metric-store-queries.go index 7a04efc4..b8e3a94a 100644 --- a/internal/metricstoreclient/cc-metric-store-queries.go +++ b/internal/metricstoreclient/cc-metric-store-queries.go @@ -37,23 +37,13 @@ package metricstoreclient import ( "fmt" - "strconv" "github.com/ClusterCockpit/cc-backend/pkg/archive" + "github.com/ClusterCockpit/cc-backend/pkg/metricstore" cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger" "github.com/ClusterCockpit/cc-lib/v2/schema" ) -// Scope string constants used in API queries. -// Pre-converted to avoid repeated allocations during query building. -var ( - hwthreadString = string(schema.MetricScopeHWThread) - coreString = string(schema.MetricScopeCore) - memoryDomainString = string(schema.MetricScopeMemoryDomain) - socketString = string(schema.MetricScopeSocket) - acceleratorString = string(schema.MetricScopeAccelerator) -) - // buildQueries constructs API queries for job-specific metric data. // It iterates through metrics, scopes, and job resources to build the complete query set. // @@ -126,21 +116,27 @@ func (ccms *CCMetricStore) buildQueries( hwthreads = topology.Node } - // Note: Expected exceptions will return as empty slices -> Continue - hostQueries, hostScopes := buildScopeQueries( + scopeResults, ok := metricstore.BuildScopeQueries( nativeScope, requestedScope, remoteName, host.Hostname, topology, hwthreads, host.Accelerators, - resolution, ) - // Note: Unexpected errors, such as unhandled cases, will return as nils -> Error - if hostQueries == nil && hostScopes == nil { + if !ok { return nil, nil, fmt.Errorf("METRICDATA/EXTERNAL-CCMS > TODO: unhandled case: native-scope=%s, requested-scope=%s", nativeScope, requestedScope) } - queries = append(queries, hostQueries...) - assignedScope = append(assignedScope, hostScopes...) + for _, sr := range scopeResults { + queries = append(queries, APIQuery{ + Metric: sr.Metric, + Hostname: sr.Hostname, + Aggregate: sr.Aggregate, + Type: sr.Type, + TypeIds: sr.TypeIds, + Resolution: resolution, + }) + assignedScope = append(assignedScope, sr.Scope) + } } } } @@ -231,19 +227,27 @@ func (ccms *CCMetricStore) buildNodeQueries( continue scopesLoop } - nodeQueries, nodeScopes := buildScopeQueries( + scopeResults, ok := metricstore.BuildScopeQueries( nativeScope, requestedScope, remoteName, hostname, topology, topology.Node, acceleratorIds, - resolution, ) - if len(nodeQueries) == 0 && len(nodeScopes) == 0 { + if !ok { return nil, nil, fmt.Errorf("METRICDATA/EXTERNAL-CCMS > TODO: unhandled case: native-scope=%s, requested-scope=%s", nativeScope, requestedScope) } - queries = append(queries, nodeQueries...) - assignedScope = append(assignedScope, nodeScopes...) + for _, sr := range scopeResults { + queries = append(queries, APIQuery{ + Metric: sr.Metric, + Hostname: sr.Hostname, + Aggregate: sr.Aggregate, + Type: sr.Type, + TypeIds: sr.TypeIds, + Resolution: resolution, + }) + assignedScope = append(assignedScope, sr.Scope) + } } } } @@ -251,277 +255,3 @@ func (ccms *CCMetricStore) buildNodeQueries( return queries, assignedScope, nil } -// buildScopeQueries generates API queries for a given scope transformation. -// It returns a slice of queries and corresponding assigned scopes. -// Some transformations (e.g., HWThread -> Core/Socket) may generate multiple queries. -func buildScopeQueries( - nativeScope, requestedScope schema.MetricScope, - metric, hostname string, - topology *schema.Topology, - hwthreads []int, - accelerators []string, - resolution int, -) ([]APIQuery, []schema.MetricScope) { - scope := nativeScope.Max(requestedScope) - queries := []APIQuery{} - scopes := []schema.MetricScope{} - - hwthreadsStr := intToStringSlice(hwthreads) - - // Accelerator -> Accelerator (Use "accelerator" scope if requested scope is lower than node) - if nativeScope == schema.MetricScopeAccelerator && scope.LT(schema.MetricScopeNode) { - if scope != schema.MetricScopeAccelerator { - // Expected Exception -> Continue -> Return Empty Slices - return queries, scopes - } - - queries = append(queries, APIQuery{ - Metric: metric, - Hostname: hostname, - Aggregate: false, - Type: &acceleratorString, - TypeIds: accelerators, - Resolution: resolution, - }) - scopes = append(scopes, schema.MetricScopeAccelerator) - return queries, scopes - } - - // Accelerator -> Node - if nativeScope == schema.MetricScopeAccelerator && scope == schema.MetricScopeNode { - if len(accelerators) == 0 { - // Expected Exception -> Continue -> Return Empty Slices - return queries, scopes - } - - queries = append(queries, APIQuery{ - Metric: metric, - Hostname: hostname, - Aggregate: true, - Type: &acceleratorString, - TypeIds: accelerators, - Resolution: resolution, - }) - scopes = append(scopes, scope) - return queries, scopes - } - - // HWThread -> HWThread - if nativeScope == schema.MetricScopeHWThread && scope == schema.MetricScopeHWThread { - queries = append(queries, APIQuery{ - Metric: metric, - Hostname: hostname, - Aggregate: false, - Type: &hwthreadString, - TypeIds: hwthreadsStr, - Resolution: resolution, - }) - scopes = append(scopes, scope) - return queries, scopes - } - - // HWThread -> Core - if nativeScope == schema.MetricScopeHWThread && scope == schema.MetricScopeCore { - cores, _ := topology.GetCoresFromHWThreads(hwthreads) - for _, core := range cores { - queries = append(queries, APIQuery{ - Metric: metric, - Hostname: hostname, - Aggregate: true, - Type: &hwthreadString, - TypeIds: intToStringSlice(topology.Core[core]), - Resolution: resolution, - }) - scopes = append(scopes, scope) - } - return queries, scopes - } - - // HWThread -> Socket - if nativeScope == schema.MetricScopeHWThread && scope == schema.MetricScopeSocket { - sockets, _ := topology.GetSocketsFromHWThreads(hwthreads) - for _, socket := range sockets { - queries = append(queries, APIQuery{ - Metric: metric, - Hostname: hostname, - Aggregate: true, - Type: &hwthreadString, - TypeIds: intToStringSlice(topology.Socket[socket]), - Resolution: resolution, - }) - scopes = append(scopes, scope) - } - return queries, scopes - } - - // HWThread -> Node - if nativeScope == schema.MetricScopeHWThread && scope == schema.MetricScopeNode { - queries = append(queries, APIQuery{ - Metric: metric, - Hostname: hostname, - Aggregate: true, - Type: &hwthreadString, - TypeIds: hwthreadsStr, - Resolution: resolution, - }) - scopes = append(scopes, scope) - return queries, scopes - } - - // Core -> Core - if nativeScope == schema.MetricScopeCore && scope == schema.MetricScopeCore { - cores, _ := topology.GetCoresFromHWThreads(hwthreads) - queries = append(queries, APIQuery{ - Metric: metric, - Hostname: hostname, - Aggregate: false, - Type: &coreString, - TypeIds: intToStringSlice(cores), - Resolution: resolution, - }) - scopes = append(scopes, scope) - return queries, scopes - } - - // Core -> Socket - if nativeScope == schema.MetricScopeCore && scope == schema.MetricScopeSocket { - sockets, _ := topology.GetSocketsFromCores(hwthreads) - for _, socket := range sockets { - queries = append(queries, APIQuery{ - Metric: metric, - Hostname: hostname, - Aggregate: true, - Type: &coreString, - TypeIds: intToStringSlice(topology.Socket[socket]), - Resolution: resolution, - }) - scopes = append(scopes, scope) - } - return queries, scopes - } - - // Core -> Node - if nativeScope == schema.MetricScopeCore && scope == schema.MetricScopeNode { - cores, _ := topology.GetCoresFromHWThreads(hwthreads) - queries = append(queries, APIQuery{ - Metric: metric, - Hostname: hostname, - Aggregate: true, - Type: &coreString, - TypeIds: intToStringSlice(cores), - Resolution: resolution, - }) - scopes = append(scopes, scope) - return queries, scopes - } - - // MemoryDomain -> MemoryDomain - if nativeScope == schema.MetricScopeMemoryDomain && scope == schema.MetricScopeMemoryDomain { - memDomains, _ := topology.GetMemoryDomainsFromHWThreads(hwthreads) - queries = append(queries, APIQuery{ - Metric: metric, - Hostname: hostname, - Aggregate: false, - Type: &memoryDomainString, - TypeIds: intToStringSlice(memDomains), - Resolution: resolution, - }) - scopes = append(scopes, scope) - return queries, scopes - } - - // MemoryDomain -> Node - if nativeScope == schema.MetricScopeMemoryDomain && scope == schema.MetricScopeNode { - memDomains, _ := topology.GetMemoryDomainsFromHWThreads(hwthreads) - queries = append(queries, APIQuery{ - Metric: metric, - Hostname: hostname, - Aggregate: true, - Type: &memoryDomainString, - TypeIds: intToStringSlice(memDomains), - Resolution: resolution, - }) - scopes = append(scopes, scope) - return queries, scopes - } - - // MemoryDomain -> Socket - if nativeScope == schema.MetricScopeMemoryDomain && scope == schema.MetricScopeSocket { - memDomains, _ := topology.GetMemoryDomainsFromHWThreads(hwthreads) - socketToDomains, err := topology.GetMemoryDomainsBySocket(memDomains) - if err != nil { - cclog.Errorf("Error mapping memory domains to sockets, return unchanged: %v", err) - // Rare Error Case -> Still Continue -> Return Empty Slices - return queries, scopes - } - - // Create a query for each socket - for _, domains := range socketToDomains { - queries = append(queries, APIQuery{ - Metric: metric, - Hostname: hostname, - Aggregate: true, - Type: &memoryDomainString, - TypeIds: intToStringSlice(domains), - Resolution: resolution, - }) - // Add scope for each query, not just once - scopes = append(scopes, scope) - } - return queries, scopes - } - - // Socket -> Socket - if nativeScope == schema.MetricScopeSocket && scope == schema.MetricScopeSocket { - sockets, _ := topology.GetSocketsFromHWThreads(hwthreads) - queries = append(queries, APIQuery{ - Metric: metric, - Hostname: hostname, - Aggregate: false, - Type: &socketString, - TypeIds: intToStringSlice(sockets), - Resolution: resolution, - }) - scopes = append(scopes, scope) - return queries, scopes - } - - // Socket -> Node - if nativeScope == schema.MetricScopeSocket && scope == schema.MetricScopeNode { - sockets, _ := topology.GetSocketsFromHWThreads(hwthreads) - queries = append(queries, APIQuery{ - Metric: metric, - Hostname: hostname, - Aggregate: true, - Type: &socketString, - TypeIds: intToStringSlice(sockets), - Resolution: resolution, - }) - scopes = append(scopes, scope) - return queries, scopes - } - - // Node -> Node - if nativeScope == schema.MetricScopeNode && scope == schema.MetricScopeNode { - queries = append(queries, APIQuery{ - Metric: metric, - Hostname: hostname, - Resolution: resolution, - }) - scopes = append(scopes, scope) - return queries, scopes - } - - // Unhandled Case -> Error -> Return nils - return nil, nil -} - -// intToStringSlice converts a slice of integers to a slice of strings. -// Used to convert hardware IDs (core IDs, socket IDs, etc.) to the string format required by the API. -func intToStringSlice(is []int) []string { - ss := make([]string, len(is)) - for i, x := range is { - ss[i] = strconv.Itoa(x) - } - return ss -} diff --git a/internal/metricstoreclient/cc-metric-store.go b/internal/metricstoreclient/cc-metric-store.go index e2a84466..2f13ade6 100644 --- a/internal/metricstoreclient/cc-metric-store.go +++ b/internal/metricstoreclient/cc-metric-store.go @@ -63,7 +63,7 @@ import ( "time" "github.com/ClusterCockpit/cc-backend/pkg/archive" - "github.com/ClusterCockpit/cc-backend/pkg/metricstore" + ms "github.com/ClusterCockpit/cc-backend/pkg/metricstore" cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger" "github.com/ClusterCockpit/cc-lib/v2/schema" ) @@ -331,7 +331,7 @@ func (ccms *CCMetricStore) LoadData( } } - sanitizeStats(&res.Avg, &res.Min, &res.Max) + ms.SanitizeStats(&res.Avg, &res.Min, &res.Max) jobMetric.Series = append(jobMetric.Series, schema.Series{ Hostname: query.Hostname, @@ -494,7 +494,7 @@ func (ccms *CCMetricStore) LoadScopedStats( } } - sanitizeStats(&res.Avg, &res.Min, &res.Max) + ms.SanitizeStats(&res.Avg, &res.Min, &res.Max) scopedJobStats[metric][scope] = append(scopedJobStats[metric][scope], &schema.ScopedStats{ Hostname: query.Hostname, @@ -584,7 +584,7 @@ func (ccms *CCMetricStore) LoadNodeData( errors = append(errors, fmt.Sprintf("fetching %s for node %s failed: %s", metric, query.Hostname, *qdata.Error)) } - sanitizeStats(&qdata.Avg, &qdata.Min, &qdata.Max) + ms.SanitizeStats(&qdata.Avg, &qdata.Min, &qdata.Max) hostdata, ok := data[query.Hostname] if !ok { @@ -756,7 +756,7 @@ func (ccms *CCMetricStore) LoadNodeListData( } } - sanitizeStats(&res.Avg, &res.Min, &res.Max) + ms.SanitizeStats(&res.Avg, &res.Min, &res.Max) scopeData.Series = append(scopeData.Series, schema.Series{ Hostname: query.Hostname, @@ -784,8 +784,8 @@ func (ccms *CCMetricStore) LoadNodeListData( // returns the per-node health check results. func (ccms *CCMetricStore) HealthCheck(cluster string, nodes []string, metrics []string, -) (map[string]metricstore.HealthCheckResult, error) { - req := metricstore.HealthCheckReq{ +) (map[string]ms.HealthCheckResult, error) { + req := ms.HealthCheckReq{ Cluster: cluster, Nodes: nodes, MetricNames: metrics, @@ -818,7 +818,7 @@ func (ccms *CCMetricStore) HealthCheck(cluster string, return nil, fmt.Errorf("'%s': HTTP Status: %s", endpoint, res.Status) } - var results map[string]metricstore.HealthCheckResult + var results map[string]ms.HealthCheckResult if err := json.NewDecoder(bufio.NewReader(res.Body)).Decode(&results); err != nil { cclog.Errorf("Error while decoding health check response: %s", err.Error()) return nil, err @@ -827,16 +827,6 @@ func (ccms *CCMetricStore) HealthCheck(cluster string, return results, nil } -// sanitizeStats replaces NaN values in statistics with 0 to enable JSON marshaling. -// Regular float64 values cannot be JSONed when NaN. -func sanitizeStats(avg, min, max *schema.Float) { - if avg.IsNaN() || min.IsNaN() || max.IsNaN() { - *avg = schema.Float(0) - *min = schema.Float(0) - *max = schema.Float(0) - } -} - // hasNaNStats returns true if any of the statistics contain NaN values. func hasNaNStats(avg, min, max schema.Float) bool { return avg.IsNaN() || min.IsNaN() || max.IsNaN() diff --git a/pkg/metricstore/metricstore.go b/pkg/metricstore/metricstore.go index 6d49624a..b6fbb51a 100644 --- a/pkg/metricstore/metricstore.go +++ b/pkg/metricstore/metricstore.go @@ -235,7 +235,7 @@ func InitMetrics(metrics map[string]MetricConfig) { // This function is safe for concurrent use after initialization. func GetMemoryStore() *MemoryStore { if msInstance == nil { - cclog.Fatalf("[METRICSTORE]> MemoryStore not initialized!") + cclog.Warnf("[METRICSTORE]> MemoryStore not initialized!") } return msInstance diff --git a/pkg/metricstore/query.go b/pkg/metricstore/query.go index 735c45d6..ed55521f 100644 --- a/pkg/metricstore/query.go +++ b/pkg/metricstore/query.go @@ -29,7 +29,6 @@ package metricstore import ( "context" "fmt" - "strconv" "strings" "time" @@ -186,7 +185,7 @@ func (ccms *InternalMetricStore) LoadData( } } - sanitizeStats(&res) + SanitizeStats(&res.Avg, &res.Min, &res.Max) jobMetric.Series = append(jobMetric.Series, schema.Series{ Hostname: query.Hostname, @@ -216,18 +215,6 @@ func (ccms *InternalMetricStore) LoadData( return jobData, nil } -// Pre-converted scope strings avoid repeated string(MetricScope) allocations during -// query construction. These are used in APIQuery.Type field throughout buildQueries -// and buildNodeQueries functions. Converting once at package initialization improves -// performance for high-volume query building. -var ( - hwthreadString = string(schema.MetricScopeHWThread) - coreString = string(schema.MetricScopeCore) - memoryDomainString = string(schema.MetricScopeMemoryDomain) - socketString = string(schema.MetricScopeSocket) - acceleratorString = string(schema.MetricScopeAccelerator) -) - // buildQueries constructs APIQuery structures with automatic scope transformation for a job. // // This function implements the core scope transformation logic, handling all combinations of @@ -293,9 +280,10 @@ func buildQueries( } } - // Avoid duplicates using map for O(1) lookup - handledScopes := make(map[schema.MetricScope]bool, 3) + // Avoid duplicates... + handledScopes := make([]schema.MetricScope, 0, 3) + scopesLoop: for _, requestedScope := range scopes { nativeScope := mc.Scope if nativeScope == schema.MetricScopeAccelerator && job.NumAcc == 0 { @@ -303,10 +291,12 @@ func buildQueries( } scope := nativeScope.Max(requestedScope) - if handledScopes[scope] { - continue + for _, s := range handledScopes { + if scope == s { + continue scopesLoop + } } - handledScopes[scope] = true + handledScopes = append(handledScopes, scope) for _, host := range job.Resources { hwthreads := host.HWThreads @@ -314,224 +304,27 @@ func buildQueries( hwthreads = topology.Node } - // Accelerator -> Accelerator (Use "accelerator" scope if requested scope is lower than node) - if nativeScope == schema.MetricScopeAccelerator && scope.LT(schema.MetricScopeNode) { - if scope != schema.MetricScopeAccelerator { - // Skip all other catched cases - continue - } + scopeResults, ok := BuildScopeQueries( + nativeScope, requestedScope, + metric, host.Hostname, + &topology, hwthreads, host.Accelerators, + ) + if !ok { + return nil, nil, fmt.Errorf("METRICDATA/INTERNAL-CCMS > TODO: unhandled case: native-scope=%s, requested-scope=%s", nativeScope, requestedScope) + } + + for _, sr := range scopeResults { queries = append(queries, APIQuery{ - Metric: metric, - Hostname: host.Hostname, - Aggregate: false, - Type: &acceleratorString, - TypeIds: host.Accelerators, + Metric: sr.Metric, + Hostname: sr.Hostname, + Aggregate: sr.Aggregate, + Type: sr.Type, + TypeIds: sr.TypeIds, Resolution: resolution, }) - assignedScope = append(assignedScope, schema.MetricScopeAccelerator) - continue + assignedScope = append(assignedScope, sr.Scope) } - - // Accelerator -> Node - if nativeScope == schema.MetricScopeAccelerator && scope == schema.MetricScopeNode { - if len(host.Accelerators) == 0 { - continue - } - - queries = append(queries, APIQuery{ - Metric: metric, - Hostname: host.Hostname, - Aggregate: true, - Type: &acceleratorString, - TypeIds: host.Accelerators, - Resolution: resolution, - }) - assignedScope = append(assignedScope, scope) - continue - } - - // HWThread -> HWThread - if nativeScope == schema.MetricScopeHWThread && scope == schema.MetricScopeHWThread { - queries = append(queries, APIQuery{ - Metric: metric, - Hostname: host.Hostname, - Aggregate: false, - Type: &hwthreadString, - TypeIds: intToStringSlice(hwthreads), - Resolution: resolution, - }) - assignedScope = append(assignedScope, scope) - continue - } - - // HWThread -> Core - if nativeScope == schema.MetricScopeHWThread && scope == schema.MetricScopeCore { - cores, _ := topology.GetCoresFromHWThreads(hwthreads) - for _, core := range cores { - queries = append(queries, APIQuery{ - Metric: metric, - Hostname: host.Hostname, - Aggregate: true, - Type: &hwthreadString, - TypeIds: intToStringSlice(topology.Core[core]), - Resolution: resolution, - }) - assignedScope = append(assignedScope, scope) - } - continue - } - - // HWThread -> Socket - if nativeScope == schema.MetricScopeHWThread && scope == schema.MetricScopeSocket { - sockets, _ := topology.GetSocketsFromHWThreads(hwthreads) - for _, socket := range sockets { - queries = append(queries, APIQuery{ - Metric: metric, - Hostname: host.Hostname, - Aggregate: true, - Type: &hwthreadString, - TypeIds: intToStringSlice(topology.Socket[socket]), - Resolution: resolution, - }) - assignedScope = append(assignedScope, scope) - } - continue - } - - // HWThread -> Node - if nativeScope == schema.MetricScopeHWThread && scope == schema.MetricScopeNode { - queries = append(queries, APIQuery{ - Metric: metric, - Hostname: host.Hostname, - Aggregate: true, - Type: &hwthreadString, - TypeIds: intToStringSlice(hwthreads), - Resolution: resolution, - }) - assignedScope = append(assignedScope, scope) - continue - } - - // Core -> Core - if nativeScope == schema.MetricScopeCore && scope == schema.MetricScopeCore { - cores, _ := topology.GetCoresFromHWThreads(hwthreads) - queries = append(queries, APIQuery{ - Metric: metric, - Hostname: host.Hostname, - Aggregate: false, - Type: &coreString, - TypeIds: intToStringSlice(cores), - Resolution: resolution, - }) - assignedScope = append(assignedScope, scope) - continue - } - - // Core -> Socket - if nativeScope == schema.MetricScopeCore && scope == schema.MetricScopeSocket { - sockets, _ := topology.GetSocketsFromCores(hwthreads) - for _, socket := range sockets { - queries = append(queries, APIQuery{ - Metric: metric, - Hostname: host.Hostname, - Aggregate: true, - Type: &coreString, - TypeIds: intToStringSlice(topology.Socket[socket]), - Resolution: resolution, - }) - assignedScope = append(assignedScope, scope) - } - continue - } - - // Core -> Node - if nativeScope == schema.MetricScopeCore && scope == schema.MetricScopeNode { - cores, _ := topology.GetCoresFromHWThreads(hwthreads) - queries = append(queries, APIQuery{ - Metric: metric, - Hostname: host.Hostname, - Aggregate: true, - Type: &coreString, - TypeIds: intToStringSlice(cores), - Resolution: resolution, - }) - assignedScope = append(assignedScope, scope) - continue - } - - // MemoryDomain -> MemoryDomain - if nativeScope == schema.MetricScopeMemoryDomain && scope == schema.MetricScopeMemoryDomain { - sockets, _ := topology.GetMemoryDomainsFromHWThreads(hwthreads) - queries = append(queries, APIQuery{ - Metric: metric, - Hostname: host.Hostname, - Aggregate: false, - Type: &memoryDomainString, - TypeIds: intToStringSlice(sockets), - Resolution: resolution, - }) - assignedScope = append(assignedScope, scope) - continue - } - - // MemoryDomain -> Node - if nativeScope == schema.MetricScopeMemoryDomain && scope == schema.MetricScopeNode { - sockets, _ := topology.GetMemoryDomainsFromHWThreads(hwthreads) - queries = append(queries, APIQuery{ - Metric: metric, - Hostname: host.Hostname, - Aggregate: true, - Type: &memoryDomainString, - TypeIds: intToStringSlice(sockets), - Resolution: resolution, - }) - assignedScope = append(assignedScope, scope) - continue - } - - // Socket -> Socket - if nativeScope == schema.MetricScopeSocket && scope == schema.MetricScopeSocket { - sockets, _ := topology.GetSocketsFromHWThreads(hwthreads) - queries = append(queries, APIQuery{ - Metric: metric, - Hostname: host.Hostname, - Aggregate: false, - Type: &socketString, - TypeIds: intToStringSlice(sockets), - Resolution: resolution, - }) - assignedScope = append(assignedScope, scope) - continue - } - - // Socket -> Node - if nativeScope == schema.MetricScopeSocket && scope == schema.MetricScopeNode { - sockets, _ := topology.GetSocketsFromHWThreads(hwthreads) - queries = append(queries, APIQuery{ - Metric: metric, - Hostname: host.Hostname, - Aggregate: true, - Type: &socketString, - TypeIds: intToStringSlice(sockets), - Resolution: resolution, - }) - assignedScope = append(assignedScope, scope) - continue - } - - // Node -> Node - if nativeScope == schema.MetricScopeNode && scope == schema.MetricScopeNode { - queries = append(queries, APIQuery{ - Metric: metric, - Hostname: host.Hostname, - Resolution: resolution, - }) - assignedScope = append(assignedScope, scope) - continue - } - - return nil, nil, fmt.Errorf("METRICDATA/INTERNAL-CCMS > TODO: unhandled case: native-scope=%s, requested-scope=%s", nativeScope, requestedScope) } } } @@ -695,7 +488,7 @@ func (ccms *InternalMetricStore) LoadScopedStats( } } - sanitizeStats(&res) + SanitizeStats(&res.Avg, &res.Min, &res.Max) scopedJobStats[metric][scope] = append(scopedJobStats[metric][scope], &schema.ScopedStats{ Hostname: query.Hostname, @@ -796,7 +589,7 @@ func (ccms *InternalMetricStore) LoadNodeData( errors = append(errors, fmt.Sprintf("fetching %s for node %s failed: %s", metric, query.Hostname, *qdata.Error)) } - sanitizeStats(&qdata) + SanitizeStats(&qdata.Avg, &qdata.Min, &qdata.Max) hostdata, ok := data[query.Hostname] if !ok { @@ -977,7 +770,7 @@ func (ccms *InternalMetricStore) LoadNodeListData( } } - sanitizeStats(&res) + SanitizeStats(&res.Avg, &res.Min, &res.Max) scopeData.Series = append(scopeData.Series, schema.Series{ Hostname: query.Hostname, @@ -1060,17 +853,20 @@ func buildNodeQueries( } } - // Avoid duplicates using map for O(1) lookup - handledScopes := make(map[schema.MetricScope]bool, 3) + // Avoid duplicates... + handledScopes := make([]schema.MetricScope, 0, 3) + nodeScopesLoop: for _, requestedScope := range scopes { nativeScope := mc.Scope scope := nativeScope.Max(requestedScope) - if handledScopes[scope] { - continue + for _, s := range handledScopes { + if scope == s { + continue nodeScopesLoop + } } - handledScopes[scope] = true + handledScopes = append(handledScopes, scope) for _, hostname := range nodes { @@ -1086,8 +882,7 @@ func buildNodeQueries( } } - // Always full node hwthread id list, no partial queries expected -> Use "topology.Node" directly where applicable - // Always full accelerator id list, no partial queries expected -> Use "acceleratorIds" directly where applicable + // Always full node hwthread id list, no partial queries expected topology := subClusterTopol.Topology acceleratorIds := topology.GetAcceleratorIDs() @@ -1096,262 +891,30 @@ func buildNodeQueries( continue } - // Accelerator -> Accelerator (Use "accelerator" scope if requested scope is lower than node) - if nativeScope == schema.MetricScopeAccelerator && scope.LT(schema.MetricScopeNode) { - if scope != schema.MetricScopeAccelerator { - // Skip all other catched cases - continue - } + scopeResults, ok := BuildScopeQueries( + nativeScope, requestedScope, + metric, hostname, + &topology, topology.Node, acceleratorIds, + ) + if !ok { + return nil, nil, fmt.Errorf("METRICDATA/INTERNAL-CCMS > TODO: unhandled case: native-scope=%s, requested-scope=%s", nativeScope, requestedScope) + } + + for _, sr := range scopeResults { queries = append(queries, APIQuery{ - Metric: metric, - Hostname: hostname, - Aggregate: false, - Type: &acceleratorString, - TypeIds: acceleratorIds, + Metric: sr.Metric, + Hostname: sr.Hostname, + Aggregate: sr.Aggregate, + Type: sr.Type, + TypeIds: sr.TypeIds, Resolution: resolution, }) - assignedScope = append(assignedScope, schema.MetricScopeAccelerator) - continue + assignedScope = append(assignedScope, sr.Scope) } - - // Accelerator -> Node - if nativeScope == schema.MetricScopeAccelerator && scope == schema.MetricScopeNode { - if len(acceleratorIds) == 0 { - continue - } - - queries = append(queries, APIQuery{ - Metric: metric, - Hostname: hostname, - Aggregate: true, - Type: &acceleratorString, - TypeIds: acceleratorIds, - Resolution: resolution, - }) - assignedScope = append(assignedScope, scope) - continue - } - - // HWThread -> HWThread - if nativeScope == schema.MetricScopeHWThread && scope == schema.MetricScopeHWThread { - queries = append(queries, APIQuery{ - Metric: metric, - Hostname: hostname, - Aggregate: false, - Type: &hwthreadString, - TypeIds: intToStringSlice(topology.Node), - Resolution: resolution, - }) - assignedScope = append(assignedScope, scope) - continue - } - - // HWThread -> Core - if nativeScope == schema.MetricScopeHWThread && scope == schema.MetricScopeCore { - cores, _ := topology.GetCoresFromHWThreads(topology.Node) - for _, core := range cores { - queries = append(queries, APIQuery{ - Metric: metric, - Hostname: hostname, - Aggregate: true, - Type: &hwthreadString, - TypeIds: intToStringSlice(topology.Core[core]), - Resolution: resolution, - }) - assignedScope = append(assignedScope, scope) - } - continue - } - - // HWThread -> Socket - if nativeScope == schema.MetricScopeHWThread && scope == schema.MetricScopeSocket { - sockets, _ := topology.GetSocketsFromHWThreads(topology.Node) - for _, socket := range sockets { - queries = append(queries, APIQuery{ - Metric: metric, - Hostname: hostname, - Aggregate: true, - Type: &hwthreadString, - TypeIds: intToStringSlice(topology.Socket[socket]), - Resolution: resolution, - }) - assignedScope = append(assignedScope, scope) - } - continue - } - - // HWThread -> Node - if nativeScope == schema.MetricScopeHWThread && scope == schema.MetricScopeNode { - queries = append(queries, APIQuery{ - Metric: metric, - Hostname: hostname, - Aggregate: true, - Type: &hwthreadString, - TypeIds: intToStringSlice(topology.Node), - Resolution: resolution, - }) - assignedScope = append(assignedScope, scope) - continue - } - - // Core -> Core - if nativeScope == schema.MetricScopeCore && scope == schema.MetricScopeCore { - cores, _ := topology.GetCoresFromHWThreads(topology.Node) - queries = append(queries, APIQuery{ - Metric: metric, - Hostname: hostname, - Aggregate: false, - Type: &coreString, - TypeIds: intToStringSlice(cores), - Resolution: resolution, - }) - assignedScope = append(assignedScope, scope) - continue - } - - // Core -> Socket - if nativeScope == schema.MetricScopeCore && scope == schema.MetricScopeSocket { - sockets, _ := topology.GetSocketsFromCores(topology.Node) - for _, socket := range sockets { - queries = append(queries, APIQuery{ - Metric: metric, - Hostname: hostname, - Aggregate: true, - Type: &coreString, - TypeIds: intToStringSlice(topology.Socket[socket]), - Resolution: resolution, - }) - assignedScope = append(assignedScope, scope) - } - continue - } - - // Core -> Node - if nativeScope == schema.MetricScopeCore && scope == schema.MetricScopeNode { - cores, _ := topology.GetCoresFromHWThreads(topology.Node) - queries = append(queries, APIQuery{ - Metric: metric, - Hostname: hostname, - Aggregate: true, - Type: &coreString, - TypeIds: intToStringSlice(cores), - Resolution: resolution, - }) - assignedScope = append(assignedScope, scope) - continue - } - - // MemoryDomain -> MemoryDomain - if nativeScope == schema.MetricScopeMemoryDomain && scope == schema.MetricScopeMemoryDomain { - sockets, _ := topology.GetMemoryDomainsFromHWThreads(topology.Node) - queries = append(queries, APIQuery{ - Metric: metric, - Hostname: hostname, - Aggregate: false, - Type: &memoryDomainString, - TypeIds: intToStringSlice(sockets), - Resolution: resolution, - }) - assignedScope = append(assignedScope, scope) - continue - } - - // MemoryDomain -> Node - if nativeScope == schema.MetricScopeMemoryDomain && scope == schema.MetricScopeNode { - sockets, _ := topology.GetMemoryDomainsFromHWThreads(topology.Node) - queries = append(queries, APIQuery{ - Metric: metric, - Hostname: hostname, - Aggregate: true, - Type: &memoryDomainString, - TypeIds: intToStringSlice(sockets), - Resolution: resolution, - }) - assignedScope = append(assignedScope, scope) - continue - } - - // Socket -> Socket - if nativeScope == schema.MetricScopeSocket && scope == schema.MetricScopeSocket { - sockets, _ := topology.GetSocketsFromHWThreads(topology.Node) - queries = append(queries, APIQuery{ - Metric: metric, - Hostname: hostname, - Aggregate: false, - Type: &socketString, - TypeIds: intToStringSlice(sockets), - Resolution: resolution, - }) - assignedScope = append(assignedScope, scope) - continue - } - - // Socket -> Node - if nativeScope == schema.MetricScopeSocket && scope == schema.MetricScopeNode { - sockets, _ := topology.GetSocketsFromHWThreads(topology.Node) - queries = append(queries, APIQuery{ - Metric: metric, - Hostname: hostname, - Aggregate: true, - Type: &socketString, - TypeIds: intToStringSlice(sockets), - Resolution: resolution, - }) - assignedScope = append(assignedScope, scope) - continue - } - - // Node -> Node - if nativeScope == schema.MetricScopeNode && scope == schema.MetricScopeNode { - queries = append(queries, APIQuery{ - Metric: metric, - Hostname: hostname, - Resolution: resolution, - }) - assignedScope = append(assignedScope, scope) - continue - } - - return nil, nil, fmt.Errorf("METRICDATA/INTERNAL-CCMS > TODO: unhandled case: native-scope=%s, requested-scope=%s", nativeScope, requestedScope) } } } return queries, assignedScope, nil } - -// sanitizeStats converts NaN statistics to zero for JSON compatibility. -// -// schema.Float with NaN values cannot be properly JSON-encoded, so we convert -// NaN to 0. This loses the distinction between "no data" and "zero value", -// but maintains API compatibility. -func sanitizeStats(data *APIMetricData) { - if data.Avg.IsNaN() { - data.Avg = schema.Float(0) - } - if data.Min.IsNaN() { - data.Min = schema.Float(0) - } - if data.Max.IsNaN() { - data.Max = schema.Float(0) - } -} - -// intToStringSlice converts a slice of integers to a slice of strings. -// Used to convert hardware thread/core/socket IDs from topology (int) to APIQuery TypeIds (string). -// -// Optimized to reuse a byte buffer for string conversion, reducing allocations. -func intToStringSlice(is []int) []string { - if len(is) == 0 { - return nil - } - - ss := make([]string, len(is)) - buf := make([]byte, 0, 16) // Reusable buffer for integer conversion - for i, x := range is { - buf = strconv.AppendInt(buf[:0], int64(x), 10) - ss[i] = string(buf) - } - return ss -} diff --git a/pkg/metricstore/scopequery.go b/pkg/metricstore/scopequery.go new file mode 100644 index 00000000..a414b794 --- /dev/null +++ b/pkg/metricstore/scopequery.go @@ -0,0 +1,314 @@ +// 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. + +// This file contains shared scope transformation logic used by both the internal +// metric store (pkg/metricstore) and the external cc-metric-store client +// (internal/metricstoreclient). It extracts the common algorithm for mapping +// between native metric scopes and requested scopes based on cluster topology. +package metricstore + +import ( + "strconv" + + cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger" + "github.com/ClusterCockpit/cc-lib/v2/schema" +) + +// Pre-converted scope strings avoid repeated string(MetricScope) allocations +// during query construction. Used in ScopeQueryResult.Type field. +var ( + HWThreadString = string(schema.MetricScopeHWThread) + CoreString = string(schema.MetricScopeCore) + MemoryDomainString = string(schema.MetricScopeMemoryDomain) + SocketString = string(schema.MetricScopeSocket) + AcceleratorString = string(schema.MetricScopeAccelerator) +) + +// ScopeQueryResult is a package-independent intermediate type returned by +// BuildScopeQueries. Each consumer converts it to their own APIQuery type +// (adding Resolution and any other package-specific fields). +type ScopeQueryResult struct { + Type *string + Metric string + Hostname string + TypeIds []string + Scope schema.MetricScope + Aggregate bool +} + +// BuildScopeQueries generates scope query results for a given scope transformation. +// It returns a slice of results and a boolean indicating success. +// An empty slice means an expected exception (skip this combination). +// ok=false means an unhandled case (caller should return an error). +func BuildScopeQueries( + nativeScope, requestedScope schema.MetricScope, + metric, hostname string, + topology *schema.Topology, + hwthreads []int, + accelerators []string, +) ([]ScopeQueryResult, bool) { + scope := nativeScope.Max(requestedScope) + results := []ScopeQueryResult{} + + hwthreadsStr := IntToStringSlice(hwthreads) + + // Accelerator -> Accelerator (Use "accelerator" scope if requested scope is lower than node) + if nativeScope == schema.MetricScopeAccelerator && scope.LT(schema.MetricScopeNode) { + if scope != schema.MetricScopeAccelerator { + // Expected Exception -> Return Empty Slice + return results, true + } + + results = append(results, ScopeQueryResult{ + Metric: metric, + Hostname: hostname, + Aggregate: false, + Type: &AcceleratorString, + TypeIds: accelerators, + Scope: schema.MetricScopeAccelerator, + }) + return results, true + } + + // Accelerator -> Node + if nativeScope == schema.MetricScopeAccelerator && scope == schema.MetricScopeNode { + if len(accelerators) == 0 { + // Expected Exception -> Return Empty Slice + return results, true + } + + results = append(results, ScopeQueryResult{ + Metric: metric, + Hostname: hostname, + Aggregate: true, + Type: &AcceleratorString, + TypeIds: accelerators, + Scope: scope, + }) + return results, true + } + + // HWThread -> HWThread + if nativeScope == schema.MetricScopeHWThread && scope == schema.MetricScopeHWThread { + results = append(results, ScopeQueryResult{ + Metric: metric, + Hostname: hostname, + Aggregate: false, + Type: &HWThreadString, + TypeIds: hwthreadsStr, + Scope: scope, + }) + return results, true + } + + // HWThread -> Core + if nativeScope == schema.MetricScopeHWThread && scope == schema.MetricScopeCore { + cores, _ := topology.GetCoresFromHWThreads(hwthreads) + for _, core := range cores { + results = append(results, ScopeQueryResult{ + Metric: metric, + Hostname: hostname, + Aggregate: true, + Type: &HWThreadString, + TypeIds: IntToStringSlice(topology.Core[core]), + Scope: scope, + }) + } + return results, true + } + + // HWThread -> Socket + if nativeScope == schema.MetricScopeHWThread && scope == schema.MetricScopeSocket { + sockets, _ := topology.GetSocketsFromHWThreads(hwthreads) + for _, socket := range sockets { + results = append(results, ScopeQueryResult{ + Metric: metric, + Hostname: hostname, + Aggregate: true, + Type: &HWThreadString, + TypeIds: IntToStringSlice(topology.Socket[socket]), + Scope: scope, + }) + } + return results, true + } + + // HWThread -> Node + if nativeScope == schema.MetricScopeHWThread && scope == schema.MetricScopeNode { + results = append(results, ScopeQueryResult{ + Metric: metric, + Hostname: hostname, + Aggregate: true, + Type: &HWThreadString, + TypeIds: hwthreadsStr, + Scope: scope, + }) + return results, true + } + + // Core -> Core + if nativeScope == schema.MetricScopeCore && scope == schema.MetricScopeCore { + cores, _ := topology.GetCoresFromHWThreads(hwthreads) + results = append(results, ScopeQueryResult{ + Metric: metric, + Hostname: hostname, + Aggregate: false, + Type: &CoreString, + TypeIds: IntToStringSlice(cores), + Scope: scope, + }) + return results, true + } + + // Core -> Socket + if nativeScope == schema.MetricScopeCore && scope == schema.MetricScopeSocket { + sockets, _ := topology.GetSocketsFromCores(hwthreads) + for _, socket := range sockets { + results = append(results, ScopeQueryResult{ + Metric: metric, + Hostname: hostname, + Aggregate: true, + Type: &CoreString, + TypeIds: IntToStringSlice(topology.Socket[socket]), + Scope: scope, + }) + } + return results, true + } + + // Core -> Node + if nativeScope == schema.MetricScopeCore && scope == schema.MetricScopeNode { + cores, _ := topology.GetCoresFromHWThreads(hwthreads) + results = append(results, ScopeQueryResult{ + Metric: metric, + Hostname: hostname, + Aggregate: true, + Type: &CoreString, + TypeIds: IntToStringSlice(cores), + Scope: scope, + }) + return results, true + } + + // MemoryDomain -> MemoryDomain + if nativeScope == schema.MetricScopeMemoryDomain && scope == schema.MetricScopeMemoryDomain { + memDomains, _ := topology.GetMemoryDomainsFromHWThreads(hwthreads) + results = append(results, ScopeQueryResult{ + Metric: metric, + Hostname: hostname, + Aggregate: false, + Type: &MemoryDomainString, + TypeIds: IntToStringSlice(memDomains), + Scope: scope, + }) + return results, true + } + + // MemoryDomain -> Socket + if nativeScope == schema.MetricScopeMemoryDomain && scope == schema.MetricScopeSocket { + memDomains, _ := topology.GetMemoryDomainsFromHWThreads(hwthreads) + socketToDomains, err := topology.GetMemoryDomainsBySocket(memDomains) + if err != nil { + cclog.Errorf("Error mapping memory domains to sockets, return unchanged: %v", err) + // Rare Error Case -> Still Continue -> Return Empty Slice + return results, true + } + + // Create a query for each socket + for _, domains := range socketToDomains { + results = append(results, ScopeQueryResult{ + Metric: metric, + Hostname: hostname, + Aggregate: true, + Type: &MemoryDomainString, + TypeIds: IntToStringSlice(domains), + Scope: scope, + }) + } + return results, true + } + + // MemoryDomain -> Node + if nativeScope == schema.MetricScopeMemoryDomain && scope == schema.MetricScopeNode { + memDomains, _ := topology.GetMemoryDomainsFromHWThreads(hwthreads) + results = append(results, ScopeQueryResult{ + Metric: metric, + Hostname: hostname, + Aggregate: true, + Type: &MemoryDomainString, + TypeIds: IntToStringSlice(memDomains), + Scope: scope, + }) + return results, true + } + + // Socket -> Socket + if nativeScope == schema.MetricScopeSocket && scope == schema.MetricScopeSocket { + sockets, _ := topology.GetSocketsFromHWThreads(hwthreads) + results = append(results, ScopeQueryResult{ + Metric: metric, + Hostname: hostname, + Aggregate: false, + Type: &SocketString, + TypeIds: IntToStringSlice(sockets), + Scope: scope, + }) + return results, true + } + + // Socket -> Node + if nativeScope == schema.MetricScopeSocket && scope == schema.MetricScopeNode { + sockets, _ := topology.GetSocketsFromHWThreads(hwthreads) + results = append(results, ScopeQueryResult{ + Metric: metric, + Hostname: hostname, + Aggregate: true, + Type: &SocketString, + TypeIds: IntToStringSlice(sockets), + Scope: scope, + }) + return results, true + } + + // Node -> Node + if nativeScope == schema.MetricScopeNode && scope == schema.MetricScopeNode { + results = append(results, ScopeQueryResult{ + Metric: metric, + Hostname: hostname, + Scope: scope, + }) + return results, true + } + + // Unhandled Case + return nil, false +} + +// IntToStringSlice converts a slice of integers to a slice of strings. +// Used to convert hardware thread/core/socket IDs from topology (int) to query TypeIds (string). +// Optimized to reuse a byte buffer for string conversion, reducing allocations. +func IntToStringSlice(is []int) []string { + if len(is) == 0 { + return nil + } + + ss := make([]string, len(is)) + buf := make([]byte, 0, 16) // Reusable buffer for integer conversion + for i, x := range is { + buf = strconv.AppendInt(buf[:0], int64(x), 10) + ss[i] = string(buf) + } + return ss +} + +// SanitizeStats replaces NaN values in statistics with 0 to enable JSON marshaling. +// If ANY of avg/min/max is NaN, ALL three are zeroed for consistency. +func SanitizeStats(avg, min, max *schema.Float) { + if avg.IsNaN() || min.IsNaN() || max.IsNaN() { + *avg = schema.Float(0) + *min = schema.Float(0) + *max = schema.Float(0) + } +} diff --git a/pkg/metricstore/scopequery_test.go b/pkg/metricstore/scopequery_test.go new file mode 100644 index 00000000..4cdfca78 --- /dev/null +++ b/pkg/metricstore/scopequery_test.go @@ -0,0 +1,273 @@ +// 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 metricstore + +import ( + "testing" + + "github.com/ClusterCockpit/cc-lib/v2/schema" +) + +// makeTopology creates a simple 2-socket, 4-core, 8-hwthread topology for testing. +// Socket 0: cores 0,1 with hwthreads 0,1,2,3 +// Socket 1: cores 2,3 with hwthreads 4,5,6,7 +// MemoryDomain 0: hwthreads 0,1,2,3 (socket 0) +// MemoryDomain 1: hwthreads 4,5,6,7 (socket 1) +func makeTopology() schema.Topology { + topo := schema.Topology{ + Node: []int{0, 1, 2, 3, 4, 5, 6, 7}, + Socket: [][]int{{0, 1, 2, 3}, {4, 5, 6, 7}}, + MemoryDomain: [][]int{{0, 1, 2, 3}, {4, 5, 6, 7}}, + Core: [][]int{{0, 1}, {2, 3}, {4, 5}, {6, 7}}, + Accelerators: []*schema.Accelerator{ + {ID: "gpu0"}, + {ID: "gpu1"}, + }, + } + return topo +} + +func TestBuildScopeQueries(t *testing.T) { + topo := makeTopology() + topo.InitTopologyMaps() + accIds := topo.GetAcceleratorIDs() + + tests := []struct { + name string + nativeScope schema.MetricScope + requestedScope schema.MetricScope + expectOk bool + expectLen int // expected number of results + expectAgg bool + expectScope schema.MetricScope + }{ + // Same-scope cases + { + name: "HWThread->HWThread", nativeScope: schema.MetricScopeHWThread, + requestedScope: schema.MetricScopeHWThread, expectOk: true, expectLen: 1, + expectAgg: false, expectScope: schema.MetricScopeHWThread, + }, + { + name: "Core->Core", nativeScope: schema.MetricScopeCore, + requestedScope: schema.MetricScopeCore, expectOk: true, expectLen: 1, + expectAgg: false, expectScope: schema.MetricScopeCore, + }, + { + name: "Socket->Socket", nativeScope: schema.MetricScopeSocket, + requestedScope: schema.MetricScopeSocket, expectOk: true, expectLen: 1, + expectAgg: false, expectScope: schema.MetricScopeSocket, + }, + { + name: "MemoryDomain->MemoryDomain", nativeScope: schema.MetricScopeMemoryDomain, + requestedScope: schema.MetricScopeMemoryDomain, expectOk: true, expectLen: 1, + expectAgg: false, expectScope: schema.MetricScopeMemoryDomain, + }, + { + name: "Node->Node", nativeScope: schema.MetricScopeNode, + requestedScope: schema.MetricScopeNode, expectOk: true, expectLen: 1, + expectAgg: false, expectScope: schema.MetricScopeNode, + }, + { + name: "Accelerator->Accelerator", nativeScope: schema.MetricScopeAccelerator, + requestedScope: schema.MetricScopeAccelerator, expectOk: true, expectLen: 1, + expectAgg: false, expectScope: schema.MetricScopeAccelerator, + }, + // Aggregation cases + { + name: "HWThread->Core", nativeScope: schema.MetricScopeHWThread, + requestedScope: schema.MetricScopeCore, expectOk: true, expectLen: 4, // 4 cores + expectAgg: true, expectScope: schema.MetricScopeCore, + }, + { + name: "HWThread->Socket", nativeScope: schema.MetricScopeHWThread, + requestedScope: schema.MetricScopeSocket, expectOk: true, expectLen: 2, // 2 sockets + expectAgg: true, expectScope: schema.MetricScopeSocket, + }, + { + name: "HWThread->Node", nativeScope: schema.MetricScopeHWThread, + requestedScope: schema.MetricScopeNode, expectOk: true, expectLen: 1, + expectAgg: true, expectScope: schema.MetricScopeNode, + }, + { + name: "Core->Socket", nativeScope: schema.MetricScopeCore, + requestedScope: schema.MetricScopeSocket, expectOk: true, expectLen: 2, // 2 sockets + expectAgg: true, expectScope: schema.MetricScopeSocket, + }, + { + name: "Core->Node", nativeScope: schema.MetricScopeCore, + requestedScope: schema.MetricScopeNode, expectOk: true, expectLen: 1, + expectAgg: true, expectScope: schema.MetricScopeNode, + }, + { + name: "Socket->Node", nativeScope: schema.MetricScopeSocket, + requestedScope: schema.MetricScopeNode, expectOk: true, expectLen: 1, + expectAgg: true, expectScope: schema.MetricScopeNode, + }, + { + name: "MemoryDomain->Node", nativeScope: schema.MetricScopeMemoryDomain, + requestedScope: schema.MetricScopeNode, expectOk: true, expectLen: 1, + expectAgg: true, expectScope: schema.MetricScopeNode, + }, + { + name: "MemoryDomain->Socket", nativeScope: schema.MetricScopeMemoryDomain, + requestedScope: schema.MetricScopeSocket, expectOk: true, expectLen: 2, // 2 sockets + expectAgg: true, expectScope: schema.MetricScopeSocket, + }, + { + name: "Accelerator->Node", nativeScope: schema.MetricScopeAccelerator, + requestedScope: schema.MetricScopeNode, expectOk: true, expectLen: 1, + expectAgg: true, expectScope: schema.MetricScopeNode, + }, + // Expected exception: Accelerator scope requested but non-accelerator scope in between + { + name: "Accelerator->Core (exception)", nativeScope: schema.MetricScopeAccelerator, + requestedScope: schema.MetricScopeCore, expectOk: true, expectLen: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + results, ok := BuildScopeQueries( + tt.nativeScope, tt.requestedScope, + "test_metric", "node001", + &topo, topo.Node, accIds, + ) + + if ok != tt.expectOk { + t.Fatalf("expected ok=%v, got ok=%v", tt.expectOk, ok) + } + + if len(results) != tt.expectLen { + t.Fatalf("expected %d results, got %d", tt.expectLen, len(results)) + } + + if tt.expectLen > 0 { + for _, r := range results { + if r.Scope != tt.expectScope { + t.Errorf("expected scope %s, got %s", tt.expectScope, r.Scope) + } + if r.Aggregate != tt.expectAgg { + t.Errorf("expected aggregate=%v, got %v", tt.expectAgg, r.Aggregate) + } + if r.Metric != "test_metric" { + t.Errorf("expected metric 'test_metric', got '%s'", r.Metric) + } + if r.Hostname != "node001" { + t.Errorf("expected hostname 'node001', got '%s'", r.Hostname) + } + } + } + }) + } +} + +func TestBuildScopeQueries_UnhandledCase(t *testing.T) { + topo := makeTopology() + topo.InitTopologyMaps() + + // Node native with HWThread requested => scope.Max = Node, but let's try an invalid combination + // Actually all valid combinations are handled. An unhandled case would be something like + // a scope that doesn't exist in the if-chain. Since all real scopes are covered, + // we test with a synthetic unhandled combination by checking the bool return. + // The function should return ok=false for truly unhandled cases. + + // For now, verify all known combinations return ok=true + scopes := []schema.MetricScope{ + schema.MetricScopeHWThread, schema.MetricScopeCore, + schema.MetricScopeSocket, schema.MetricScopeNode, + } + + for _, native := range scopes { + for _, requested := range scopes { + results, ok := BuildScopeQueries( + native, requested, + "m", "h", &topo, topo.Node, nil, + ) + if !ok { + t.Errorf("unexpected unhandled case: native=%s, requested=%s", native, requested) + } + if results == nil { + t.Errorf("results should not be nil for native=%s, requested=%s", native, requested) + } + } + } +} + +func TestIntToStringSlice(t *testing.T) { + tests := []struct { + input []int + expected []string + }{ + {nil, nil}, + {[]int{}, nil}, + {[]int{0}, []string{"0"}}, + {[]int{1, 2, 3}, []string{"1", "2", "3"}}, + {[]int{10, 100, 1000}, []string{"10", "100", "1000"}}, + } + + for _, tt := range tests { + result := IntToStringSlice(tt.input) + if len(result) != len(tt.expected) { + t.Errorf("IntToStringSlice(%v): expected len %d, got %d", tt.input, len(tt.expected), len(result)) + continue + } + for i := range result { + if result[i] != tt.expected[i] { + t.Errorf("IntToStringSlice(%v)[%d]: expected %s, got %s", tt.input, i, tt.expected[i], result[i]) + } + } + } +} + +func TestSanitizeStats(t *testing.T) { + // Test: all valid - should remain unchanged + avg, min, max := schema.Float(1.0), schema.Float(0.5), schema.Float(2.0) + SanitizeStats(&avg, &min, &max) + if avg != 1.0 || min != 0.5 || max != 2.0 { + t.Errorf("SanitizeStats should not change valid values") + } + + // Test: one NaN - all should be zeroed + avg, min, max = schema.Float(1.0), schema.Float(0.5), schema.NaN + SanitizeStats(&avg, &min, &max) + if avg != 0 || min != 0 || max != 0 { + t.Errorf("SanitizeStats should zero all when any is NaN, got avg=%v min=%v max=%v", avg, min, max) + } + + // Test: all NaN + avg, min, max = schema.NaN, schema.NaN, schema.NaN + SanitizeStats(&avg, &min, &max) + if avg != 0 || min != 0 || max != 0 { + t.Errorf("SanitizeStats should zero all NaN values") + } +} + +func TestNodeToNodeQuery(t *testing.T) { + topo := makeTopology() + topo.InitTopologyMaps() + + results, ok := BuildScopeQueries( + schema.MetricScopeNode, schema.MetricScopeNode, + "cpu_load", "node001", + &topo, topo.Node, nil, + ) + + if !ok { + t.Fatal("expected ok=true for Node->Node") + } + if len(results) != 1 { + t.Fatalf("expected 1 result, got %d", len(results)) + } + r := results[0] + if r.Type != nil { + t.Error("Node->Node should have nil Type") + } + if r.TypeIds != nil { + t.Error("Node->Node should have nil TypeIds") + } + if r.Aggregate { + t.Error("Node->Node should not aggregate") + } +} From 845d0111af29376086aeb93fee082c4fbb6e9efc Mon Sep 17 00:00:00 2001 From: Jan Eitzinger Date: Wed, 4 Mar 2026 17:31:36 +0100 Subject: [PATCH 7/8] Further consolidate and improve ccms query builder Entire-Checkpoint: d10e6221ee4f --- .../cc-metric-store-queries.go | 30 ++---- internal/metricstoreclient/cc-metric-store.go | 65 +++--------- pkg/metricstore/query.go | 99 +++++-------------- pkg/metricstore/scopequery.go | 27 +++++ 4 files changed, 69 insertions(+), 152 deletions(-) diff --git a/internal/metricstoreclient/cc-metric-store-queries.go b/internal/metricstoreclient/cc-metric-store-queries.go index b8e3a94a..1119d70c 100644 --- a/internal/metricstoreclient/cc-metric-store-queries.go +++ b/internal/metricstoreclient/cc-metric-store-queries.go @@ -79,17 +79,8 @@ func (ccms *CCMetricStore) buildQueries( } // Skip if metric is removed for subcluster - if len(mc.SubClusters) != 0 { - isRemoved := false - for _, scConfig := range mc.SubClusters { - if scConfig.Name == job.SubCluster && scConfig.Remove { - isRemoved = true - break - } - } - if isRemoved { - continue - } + if len(mc.SubClusters) != 0 && metricstore.IsMetricRemovedForSubCluster(mc, job.SubCluster) { + continue } // Avoid duplicates... @@ -123,7 +114,7 @@ func (ccms *CCMetricStore) buildQueries( ) if !ok { - return nil, nil, fmt.Errorf("METRICDATA/EXTERNAL-CCMS > TODO: unhandled case: native-scope=%s, requested-scope=%s", nativeScope, requestedScope) + return nil, nil, fmt.Errorf("METRICDATA/EXTERNAL-CCMS > unsupported scope transformation: native-scope=%s, requested-scope=%s", nativeScope, requestedScope) } for _, sr := range scopeResults { @@ -175,17 +166,8 @@ func (ccms *CCMetricStore) buildNodeQueries( } // Skip if metric is removed for subcluster - if mc.SubClusters != nil { - isRemoved := false - for _, scConfig := range mc.SubClusters { - if scConfig.Name == subCluster && scConfig.Remove { - isRemoved = true - break - } - } - if isRemoved { - continue - } + if mc.SubClusters != nil && metricstore.IsMetricRemovedForSubCluster(mc, subCluster) { + continue } // Avoid duplicates... @@ -234,7 +216,7 @@ func (ccms *CCMetricStore) buildNodeQueries( ) if !ok { - return nil, nil, fmt.Errorf("METRICDATA/EXTERNAL-CCMS > TODO: unhandled case: native-scope=%s, requested-scope=%s", nativeScope, requestedScope) + return nil, nil, fmt.Errorf("METRICDATA/EXTERNAL-CCMS > unsupported scope transformation: native-scope=%s, requested-scope=%s", nativeScope, requestedScope) } for _, sr := range scopeResults { diff --git a/internal/metricstoreclient/cc-metric-store.go b/internal/metricstoreclient/cc-metric-store.go index 2f13ade6..55dc7fb5 100644 --- a/internal/metricstoreclient/cc-metric-store.go +++ b/internal/metricstoreclient/cc-metric-store.go @@ -275,13 +275,6 @@ func (ccms *CCMetricStore) LoadData( } for i, row := range resBody.Results { - // Safety check to prevent index out of range errors - if i >= len(req.Queries) || i >= len(assignedScope) { - cclog.Warnf("Index out of range prevented: i=%d, queries=%d, assignedScope=%d", - i, len(req.Queries), len(assignedScope)) - continue - } - query := req.Queries[i] metric := query.Metric scope := assignedScope[i] @@ -318,18 +311,7 @@ func (ccms *CCMetricStore) LoadData( continue } - id := (*string)(nil) - if query.Type != nil { - // Check if ndx is within the bounds of TypeIds slice - if ndx < len(query.TypeIds) { - id = new(string) - *id = query.TypeIds[ndx] - } else { - // Log the error but continue processing - cclog.Warnf("TypeIds index out of range: %d with length %d for metric %s on host %s", - ndx, len(query.TypeIds), query.Metric, query.Hostname) - } - } + id := ms.ExtractTypeID(query.Type, query.TypeIds, ndx, query.Metric, query.Hostname) ms.SanitizeStats(&res.Avg, &res.Min, &res.Max) @@ -393,6 +375,10 @@ func (ccms *CCMetricStore) LoadStats( stats := make(map[string]map[string]schema.MetricStatistics, len(metrics)) for i, res := range resBody.Results { + if i >= len(req.Queries) { + cclog.Warnf("LoadStats: result index %d exceeds queries length %d", i, len(req.Queries)) + break + } if len(res) == 0 { // No Data Found For Metric, Logged in FetchData to Warn continue @@ -481,18 +467,7 @@ func (ccms *CCMetricStore) LoadScopedStats( continue } - id := (*string)(nil) - if query.Type != nil { - // Check if ndx is within the bounds of TypeIds slice - if ndx < len(query.TypeIds) { - id = new(string) - *id = query.TypeIds[ndx] - } else { - // Log the error but continue processing - cclog.Warnf("TypeIds index out of range: %d with length %d for metric %s on host %s", - ndx, len(query.TypeIds), query.Metric, query.Hostname) - } - } + id := ms.ExtractTypeID(query.Type, query.TypeIds, ndx, query.Metric, query.Hostname) ms.SanitizeStats(&res.Avg, &res.Min, &res.Max) @@ -582,6 +557,13 @@ func (ccms *CCMetricStore) LoadNodeData( qdata := res[0] if qdata.Error != nil { errors = append(errors, fmt.Sprintf("fetching %s for node %s failed: %s", metric, query.Hostname, *qdata.Error)) + continue + } + + mc := archive.GetMetricConfig(cluster, metric) + if mc == nil { + cclog.Warnf("Metric config not found for %s on cluster %s", metric, cluster) + continue } ms.SanitizeStats(&qdata.Avg, &qdata.Min, &qdata.Max) @@ -592,7 +574,6 @@ func (ccms *CCMetricStore) LoadNodeData( data[query.Hostname] = hostdata } - mc := archive.GetMetricConfig(cluster, metric) hostdata[metric] = append(hostdata[metric], &schema.JobMetric{ Unit: mc.Unit, Timestep: mc.Timestep, @@ -680,13 +661,6 @@ func (ccms *CCMetricStore) LoadNodeListData( } for i, row := range resBody.Results { - // Safety check to prevent index out of range errors - if i >= len(req.Queries) || i >= len(assignedScope) { - cclog.Warnf("Index out of range prevented: i=%d, queries=%d, assignedScope=%d", - i, len(req.Queries), len(assignedScope)) - continue - } - var query APIQuery if resBody.Queries != nil { if i < len(resBody.Queries) { @@ -743,18 +717,7 @@ func (ccms *CCMetricStore) LoadNodeListData( continue } - id := (*string)(nil) - if query.Type != nil { - // Check if ndx is within the bounds of TypeIds slice - if ndx < len(query.TypeIds) { - id = new(string) - *id = query.TypeIds[ndx] - } else { - // Log the error but continue processing - cclog.Warnf("TypeIds index out of range: %d with length %d for metric %s on host %s", - ndx, len(query.TypeIds), query.Metric, query.Hostname) - } - } + id := ms.ExtractTypeID(query.Type, query.TypeIds, ndx, query.Metric, query.Hostname) ms.SanitizeStats(&res.Avg, &res.Min, &res.Max) diff --git a/pkg/metricstore/query.go b/pkg/metricstore/query.go index ed55521f..8a349b5a 100644 --- a/pkg/metricstore/query.go +++ b/pkg/metricstore/query.go @@ -129,13 +129,6 @@ func (ccms *InternalMetricStore) LoadData( } for i, row := range resBody.Results { - // Safety check to prevent index out of range errors - if i >= len(req.Queries) || i >= len(assignedScope) { - cclog.Warnf("Index out of range prevented: i=%d, queries=%d, assignedScope=%d", - i, len(req.Queries), len(assignedScope)) - continue - } - query := req.Queries[i] metric := query.Metric scope := assignedScope[i] @@ -172,18 +165,7 @@ func (ccms *InternalMetricStore) LoadData( continue } - id := (*string)(nil) - if query.Type != nil { - // Check if ndx is within the bounds of TypeIds slice - if ndx < len(query.TypeIds) { - id = new(string) - *id = query.TypeIds[ndx] - } else { - // Log the error but continue processing - cclog.Warnf("TypeIds index out of range: %d with length %d for metric %s on host %s", - ndx, len(query.TypeIds), query.Metric, query.Hostname) - } - } + id := ExtractTypeID(query.Type, query.TypeIds, ndx, query.Metric, query.Hostname) SanitizeStats(&res.Avg, &res.Min, &res.Max) @@ -251,7 +233,7 @@ func buildQueries( } queries := make([]APIQuery, 0, len(metrics)*len(scopes)*len(job.Resources)) - assignedScope := []schema.MetricScope{} + assignedScope := make([]schema.MetricScope, 0, len(metrics)*len(scopes)*len(job.Resources)) subcluster, scerr := archive.GetSubCluster(job.Cluster, job.SubCluster) if scerr != nil { @@ -267,17 +249,8 @@ func buildQueries( } // Skip if metric is removed for subcluster - if len(mc.SubClusters) != 0 { - isRemoved := false - for _, scConfig := range mc.SubClusters { - if scConfig.Name == job.SubCluster && scConfig.Remove { - isRemoved = true - break - } - } - if isRemoved { - continue - } + if len(mc.SubClusters) != 0 && IsMetricRemovedForSubCluster(mc, job.SubCluster) { + continue } // Avoid duplicates... @@ -311,7 +284,7 @@ func buildQueries( ) if !ok { - return nil, nil, fmt.Errorf("METRICDATA/INTERNAL-CCMS > TODO: unhandled case: native-scope=%s, requested-scope=%s", nativeScope, requestedScope) + return nil, nil, fmt.Errorf("METRICDATA/INTERNAL-CCMS > unsupported scope transformation: native-scope=%s, requested-scope=%s", nativeScope, requestedScope) } for _, sr := range scopeResults { @@ -374,6 +347,10 @@ func (ccms *InternalMetricStore) LoadStats( stats := make(map[string]map[string]schema.MetricStatistics, len(metrics)) for i, res := range resBody.Results { + if i >= len(req.Queries) { + cclog.Warnf("LoadStats: result index %d exceeds queries length %d", i, len(req.Queries)) + break + } if len(res) == 0 { // No Data Found For Metric, Logged in FetchData to Warn continue @@ -475,18 +452,7 @@ func (ccms *InternalMetricStore) LoadScopedStats( continue } - id := (*string)(nil) - if query.Type != nil { - // Check if ndx is within the bounds of TypeIds slice - if ndx < len(query.TypeIds) { - id = new(string) - *id = query.TypeIds[ndx] - } else { - // Log the error but continue processing - cclog.Warnf("TypeIds index out of range: %d with length %d for metric %s on host %s", - ndx, len(query.TypeIds), query.Metric, query.Hostname) - } - } + id := ExtractTypeID(query.Type, query.TypeIds, ndx, query.Metric, query.Hostname) SanitizeStats(&res.Avg, &res.Min, &res.Max) @@ -587,6 +553,13 @@ func (ccms *InternalMetricStore) LoadNodeData( qdata := res[0] if qdata.Error != nil { errors = append(errors, fmt.Sprintf("fetching %s for node %s failed: %s", metric, query.Hostname, *qdata.Error)) + continue + } + + mc := archive.GetMetricConfig(cluster, metric) + if mc == nil { + cclog.Warnf("Metric config not found for %s on cluster %s", metric, cluster) + continue } SanitizeStats(&qdata.Avg, &qdata.Min, &qdata.Max) @@ -597,7 +570,6 @@ func (ccms *InternalMetricStore) LoadNodeData( data[query.Hostname] = hostdata } - mc := archive.GetMetricConfig(cluster, metric) hostdata[metric] = append(hostdata[metric], &schema.JobMetric{ Unit: mc.Unit, Timestep: mc.Timestep, @@ -694,13 +666,6 @@ func (ccms *InternalMetricStore) LoadNodeListData( } for i, row := range resBody.Results { - // Safety check to prevent index out of range errors - if i >= len(req.Queries) || i >= len(assignedScope) { - cclog.Warnf("Index out of range prevented: i=%d, queries=%d, assignedScope=%d", - i, len(req.Queries), len(assignedScope)) - continue - } - var query APIQuery if resBody.Queries != nil { if i < len(resBody.Queries) { @@ -757,18 +722,7 @@ func (ccms *InternalMetricStore) LoadNodeListData( continue } - id := (*string)(nil) - if query.Type != nil { - // Check if ndx is within the bounds of TypeIds slice - if ndx < len(query.TypeIds) { - id = new(string) - *id = query.TypeIds[ndx] - } else { - // Log the error but continue processing - cclog.Warnf("TypeIds index out of range: %d with length %d for metric %s on host %s", - ndx, len(query.TypeIds), query.Metric, query.Hostname) - } - } + id := ExtractTypeID(query.Type, query.TypeIds, ndx, query.Metric, query.Hostname) SanitizeStats(&res.Avg, &res.Min, &res.Max) @@ -819,7 +773,7 @@ func buildNodeQueries( resolution int64, ) ([]APIQuery, []schema.MetricScope, error) { queries := make([]APIQuery, 0, len(metrics)*len(scopes)*len(nodes)) - assignedScope := []schema.MetricScope{} + assignedScope := make([]schema.MetricScope, 0, len(metrics)*len(scopes)*len(nodes)) // Get Topol before loop if subCluster given var subClusterTopol *schema.SubCluster @@ -840,17 +794,8 @@ func buildNodeQueries( } // Skip if metric is removed for subcluster - if mc.SubClusters != nil { - isRemoved := false - for _, scConfig := range mc.SubClusters { - if scConfig.Name == subCluster && scConfig.Remove { - isRemoved = true - break - } - } - if isRemoved { - continue - } + if mc.SubClusters != nil && IsMetricRemovedForSubCluster(mc, subCluster) { + continue } // Avoid duplicates... @@ -898,7 +843,7 @@ func buildNodeQueries( ) if !ok { - return nil, nil, fmt.Errorf("METRICDATA/INTERNAL-CCMS > TODO: unhandled case: native-scope=%s, requested-scope=%s", nativeScope, requestedScope) + return nil, nil, fmt.Errorf("METRICDATA/INTERNAL-CCMS > unsupported scope transformation: native-scope=%s, requested-scope=%s", nativeScope, requestedScope) } for _, sr := range scopeResults { diff --git a/pkg/metricstore/scopequery.go b/pkg/metricstore/scopequery.go index a414b794..a01a9cc6 100644 --- a/pkg/metricstore/scopequery.go +++ b/pkg/metricstore/scopequery.go @@ -303,6 +303,33 @@ func IntToStringSlice(is []int) []string { return ss } +// ExtractTypeID returns the type ID at the given index from a query's TypeIds slice. +// Returns nil if queryType is nil (no type filtering). Logs a warning and returns nil +// if the index is out of range. +func ExtractTypeID(queryType *string, typeIds []string, ndx int, metric, hostname string) *string { + if queryType == nil { + return nil + } + if ndx < len(typeIds) { + id := typeIds[ndx] + return &id + } + cclog.Warnf("TypeIds index out of range: %d with length %d for metric %s on host %s", + ndx, len(typeIds), metric, hostname) + return nil +} + +// IsMetricRemovedForSubCluster checks whether a metric is marked as removed +// for the given subcluster in its per-subcluster configuration. +func IsMetricRemovedForSubCluster(mc *schema.MetricConfig, subCluster string) bool { + for _, scConfig := range mc.SubClusters { + if scConfig.Name == subCluster && scConfig.Remove { + return true + } + } + return false +} + // SanitizeStats replaces NaN values in statistics with 0 to enable JSON marshaling. // If ANY of avg/min/max is NaN, ALL three are zeroed for consistency. func SanitizeStats(avg, min, max *schema.Float) { From 47181330e9017c2a03373093f4664b53e434113f Mon Sep 17 00:00:00 2001 From: Jan Eitzinger Date: Wed, 4 Mar 2026 17:39:46 +0100 Subject: [PATCH 8/8] Update to latest cc-lib --- go.mod | 3 +-- go.sum | 6 ++---- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index 2e72e342..b03e7e0b 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ tool ( require ( github.com/99designs/gqlgen v0.17.87 - github.com/ClusterCockpit/cc-lib/v2 v2.7.0 + github.com/ClusterCockpit/cc-lib/v2 v2.8.0 github.com/ClusterCockpit/cc-line-protocol/v2 v2.4.0 github.com/Masterminds/squirrel v1.5.4 github.com/aws/aws-sdk-go-v2 v1.41.2 @@ -111,7 +111,6 @@ require ( github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect golang.org/x/mod v0.33.0 // indirect golang.org/x/net v0.51.0 // indirect golang.org/x/sync v0.19.0 // indirect diff --git a/go.sum b/go.sum index 70f98fc8..812e4b83 100644 --- a/go.sum +++ b/go.sum @@ -4,8 +4,8 @@ github.com/99designs/gqlgen v0.17.87 h1:pSnCIMhBQezAE8bc1GNmfdLXFmnWtWl1GRDFEE/n github.com/99designs/gqlgen v0.17.87/go.mod h1:fK05f1RqSNfQpd4CfW5qk/810Tqi4/56Wf6Nem0khAg= github.com/Azure/go-ntlmssp v0.1.0 h1:DjFo6YtWzNqNvQdrwEyr/e4nhU3vRiwenz5QX7sFz+A= github.com/Azure/go-ntlmssp v0.1.0/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk= -github.com/ClusterCockpit/cc-lib/v2 v2.7.0 h1:EMTShk6rMTR1wlfmQ8SVCawH1OdltUbD3kVQmaW+5pE= -github.com/ClusterCockpit/cc-lib/v2 v2.7.0/go.mod h1:0Etx8WMs0lYZ4tiOQizY18CQop+2i3WROvU9rMUxHA4= +github.com/ClusterCockpit/cc-lib/v2 v2.8.0 h1:ROduRzRuusi+6kLB991AAu3Pp2AHOasQJFJc7JU/n/E= +github.com/ClusterCockpit/cc-lib/v2 v2.8.0/go.mod h1:FwD8vnTIbBM3ngeLNKmCvp9FoSjQZm7xnuaVxEKR23o= github.com/ClusterCockpit/cc-line-protocol/v2 v2.4.0 h1:hIzxgTBWcmCIHtoDKDkSCsKCOCOwUC34sFsbD2wcW0Q= github.com/ClusterCockpit/cc-line-protocol/v2 v2.4.0/go.mod h1:y42qUu+YFmu5fdNuUAS4VbbIKxVjxCvbVqFdpdh8ahY= github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= @@ -307,8 +307,6 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= -golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0= -golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=