diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md index 28d198cc51..312bb58e6e 100644 --- a/docs/release-control/v6/internal/subsystems/monitoring.md +++ b/docs/release-control/v6/internal/subsystems/monitoring.md @@ -291,13 +291,16 @@ row timestamps and supported object aliases retain compatibility. A metric is never a timestamp, a null is never zero and a window mean is never a replacement for a missing current row. History retains measured buckets; native current readings accept only the last two steps before the requested end, preserving -sample time rather than stamping an old value as newly measured. The five-state +sample time rather than stamping an old value as newly measured. This allowance +never exceeds the live query window, even with a coarse returned step. The five-state CORE CPU vector is normalized by its sum (RRD state rates need not sum to 100). All-zero CPU or complete memory-class vectors are empty buckets, not 100% usage; explicit zero usage, all-idle CPU, zero-free RAM with used pages and zero I/O remain measured zeros. Free memory comes from `memory-free_value`, not active pages or the sum of memory classes. ARC subtraction uses the matching free-RAM -bucket. Reporting I/O values remain rates in the existing bytes/s contract; +bucket when free RAM is present; independently reported ARC remains available +without claiming measured memory usage. Pre-cancelled collection stops before +reading the catalogue or posting a reporting query. Reporting I/O values remain rates in the existing bytes/s contract; step supplies time, not a second rate division; `overlap` is not extra traffic. Legacy REST reads `/reporting/graphs` once per live/History collection and sends diff --git a/internal/truenas/client.go b/internal/truenas/client.go index 3c932617b1..a6ff48f5a6 100644 --- a/internal/truenas/client.go +++ b/internal/truenas/client.go @@ -342,6 +342,14 @@ func systemInfoFromMetricHistory(history *SystemMetricHistory) *SystemInfo { latestFree = free.Timestamp } } + // ARC can be observed independently when no free-RAM sample exists; + // retain it without implying known memory usage. With free RAM, require + // cache and free to describe the same bucket before deriving usage. + if latestFree.IsZero() { + if value, ok := latestTimeSeriesValue(history.ARCSizeBytes); ok { + system.ARCSizeBytes = int64(value) + } + } for _, arc := range history.ARCSizeBytes { if arc.Timestamp.Equal(latestFree) { system.ARCSizeBytes = int64(arc.Value) diff --git a/internal/truenas/client_test.go b/internal/truenas/client_test.go index ab68c5d3d6..b39886f444 100644 --- a/internal/truenas/client_test.go +++ b/internal/truenas/client_test.go @@ -2195,8 +2195,8 @@ func TestRESTReportingGraphFailurePreservesSnapshotTelemetry(t *testing.T) { t.Fatal("inventory lost") } } - if transport.calls != 12 { - t.Fatalf("reporting calls = %d, want bounded batch + five graphs per snapshot", transport.calls) + if transport.calls != 2*(1+len(legacyRESTReportingGraphs())) { + t.Fatalf("reporting calls = %d, want bounded batch + selected graphs per snapshot", transport.calls) } history, err := client.GetSystemMetricHistory(context.Background(), time.Hour) if err != nil { @@ -2464,7 +2464,7 @@ func TestRESTSnapshotRetainsSanitizedTelemetryFailure(t *testing.T) { } wantCalls := 1 if tc.status == 422 { - wantCalls = 6 + wantCalls = 1 + len(legacyRESTReportingGraphs()) } if transport.calls != wantCalls { t.Fatalf("reporting calls=%d, want %d", transport.calls, wantCalls) diff --git a/internal/truenas/core_reporting.go b/internal/truenas/core_reporting.go index ac3eb46ccc..0e735e6b4e 100644 --- a/internal/truenas/core_reporting.go +++ b/internal/truenas/core_reporting.go @@ -16,6 +16,9 @@ import ( // Older REST bridges without the catalogue retain the unscoped compatibility // request. Authentication, transport and malformed replies are never retried. func (c *Client) legacyReportingGraphs(ctx context.Context) ([]map[string]any, error) { + if err := ctx.Err(); err != nil { + return nil, err + } var catalogue []struct { Name string `json:"name"` Identifiers []string `json:"identifiers"` @@ -97,7 +100,9 @@ func reportingRow(response trueNASReportingGetDataResponse, index int) (time.Tim // Keep native History intact, but do not label an old RRD point as a current // reading. Two returned steps allow the usual unfinished/null RRD bucket; -// older points stay available only through History. Preserve row indices. +// older points stay available only through History. Never exceed the live +// query window even if a coarse/malformed step allows older buckets. Preserve +// row indices. func liveReportingResponses(responses []trueNASReportingGetDataResponse, end int64) []trueNASReportingGetDataResponse { live := append([]trueNASReportingGetDataResponse(nil), responses...) for i, response := range live { @@ -108,7 +113,7 @@ func liveReportingResponses(responses []trueNASReportingGetDataResponse, end int for index := range response.Data { timestamp, _, ok := reportingRow(response, index) gap := end - timestamp.Unix() - if !ok || timestamp.Unix() > end || (gap > response.Step && gap-response.Step > response.Step) { + if !ok || timestamp.Unix() > end || gap > legacyRESTTelemetryWindowSeconds || (gap > response.Step && gap-response.Step > response.Step) { live[i].Data[index] = nil } } diff --git a/internal/truenas/core_reporting_test.go b/internal/truenas/core_reporting_test.go index f9cc2f5864..08a2594fde 100644 --- a/internal/truenas/core_reporting_test.go +++ b/internal/truenas/core_reporting_test.go @@ -417,6 +417,28 @@ func TestCORE13ARCAlignedWithFreeMemory(t *testing.T) { assertCOREPoint(t, points, 0, 1789000000, 0) } +func TestCORE13CoarseStepDoesNotMakeOldRowsCurrent(t *testing.T) { + response := trueNASReportingGetDataResponse{Name: "cpu", Legend: []string{"usage"}, Start: 1789000000, End: 1789000000, Step: 3600, Data: []any{[]any{12.}}} + if history := parseSystemMetricHistory(liveReportingResponses([]trueNASReportingGetDataResponse{response}, 1789000400)); history != nil { + t.Fatal("coarse step made an old CPU sample current") + } + if history := parseSystemMetricHistory([]trueNASReportingGetDataResponse{response}); history == nil { + t.Fatal("live age filtering destroyed native History") + } +} + +func TestCORE13ARCOnlyDoesNotInventMemoryUsage(t *testing.T) { + history := &SystemMetricHistory{ARCSizeBytes: []TimeSeriesPoint{{Timestamp: time.Unix(1789000000, 0), Value: 8 << 30}}} + system := systemInfoFromMetricHistory(history) + if system.ARCSizeBytes != 8<<30 || system.Telemetry.Memory { + t.Fatal("ARC-only telemetry lost or claimed free RAM") + } + system.MemoryTotalBytes = 32 << 30 + if metricsFromTrueNASSystem(*system, 0, 0).Memory != nil { + t.Fatal("ARC-only reading invented usage") + } +} + func TestCORE13DiskTemperatureHistoryExternalTiming(t *testing.T) { response := trueNASReportingGetDataResponse{Name: "disktemp", Identifier: "disk-a", Legend: []string{"temperature"}, Start: 1789000000, End: 1789000020, Step: 10, Data: []any{[]any{40.}, []any{nil}, []any{42.}}} points := parseReportingDiskTemperatureHistory([]trueNASReportingGetDataResponse{response})["disk-a"]