diff --git a/Darling/Darling.Tests/ViewerCpuTempDbTests.cs b/Darling/Darling.Tests/ViewerCpuTempDbTests.cs index ddfa554331..e79b4c924f 100644 --- a/Darling/Darling.Tests/ViewerCpuTempDbTests.cs +++ b/Darling/Darling.Tests/ViewerCpuTempDbTests.cs @@ -25,10 +25,11 @@ namespace Darling.Tests; /// per-sample series bucketed to 's width (#4234): a bucket wider than /// one sample averages the two gauge columns, and a bucket holding exactly one physical sample is /// stamped at that sample's own raw time by the C# reader rather than the bucket grid (ruling item 3). -/// The tempdb reads mirror Lite's view-based queries (v_tempdb_stats / v_file_io_stats), unaffected by -/// #4234: the numeric(18,2) MB columns are CAST to double precision for the typed reader, -/// total_sessions_using_tempdb stays bigint (GetInt64), and the file-I/O read filters to tempdb and -/// averages stall/op per file. +/// The tempdb reads mirror Lite's view-based queries (v_tempdb_stats / v_file_io_stats): the numeric(18,2) +/// MB columns are CAST to double precision for the typed reader, total_sessions_using_tempdb stays bigint +/// (GetInt64). The usage read (TempDbTrendSql) is unaffected by #4234 — still one row per collection. The +/// file-I/O read filters to tempdb, averages stall/op per file, and — since #4234 — is bucketed the same +/// way as the File I/O tab's own per-file reads (Lite's twin bucketed first, #4340). /// public sealed class ViewerCpuTempDbSqlTests { @@ -153,18 +154,37 @@ public void TempDbTrendSql_ReadsColumnsThatExistInTheGeneratedTempDbTable() } [Fact] - public void TempDbFileIoTrendSql_FiltersToTempDb_GroupsPerFile_CastsStallToDouble() + public void TempDbFileIoTrendSql_FiltersToTempDb_GroupsPerFileAndBucket_CastsStallToDouble() { Assert.Contains("FROM v_file_io_stats", ViewerDataService.TempDbFileIoTrendSql, StringComparison.Ordinal); Assert.Contains("WHERE server_id = $1", ViewerDataService.TempDbFileIoTrendSql, StringComparison.Ordinal); Assert.Contains("collection_time >= $2", ViewerDataService.TempDbFileIoTrendSql, StringComparison.Ordinal); + Assert.Contains("collection_time <= $3", ViewerDataService.TempDbFileIoTrendSql, StringComparison.Ordinal); Assert.Contains("database_name = 'tempdb'", ViewerDataService.TempDbFileIoTrendSql, StringComparison.Ordinal); - Assert.Contains("GROUP BY collection_time, file_name", ViewerDataService.TempDbFileIoTrendSql, StringComparison.Ordinal); - Assert.Contains("ORDER BY collection_time, file_name", ViewerDataService.TempDbFileIoTrendSql, StringComparison.Ordinal); + /* #4234: grouped on file_name plus the bucket_start ordinal, not the raw collection_time column + (the pre-bucketing read grouped/ordered on the bare column). */ + Assert.Contains("GROUP BY file_name, 2", ViewerDataService.TempDbFileIoTrendSql, StringComparison.Ordinal); + Assert.Contains("ORDER BY file_name, 2", ViewerDataService.TempDbFileIoTrendSql, StringComparison.Ordinal); Assert.Contains("CAST(delta_stall_read_ms AS double precision)", ViewerDataService.TempDbFileIoTrendSql, StringComparison.Ordinal); Assert.Contains("CAST(delta_stall_write_ms AS double precision)", ViewerDataService.TempDbFileIoTrendSql, StringComparison.Ordinal); } + /// #4234 ruling item 6 (source check): the tempdb file-I/O read carries a bucket width and + /// projects the singleton-detection columns, mirroring FileIoLatencyTrendSql. Proven once by + /// hand against the pre-#4234 text: no date_bin anywhere (a per-collection read has no bucket + /// width), so this assert fails there. + [Fact] + public void TempDbFileIoTrendSql_CarriesABucketWidth_AndProjectsSingletonDetectionColumns() + { + var sql = ViewerDataService.TempDbFileIoTrendSql; + Assert.Contains("date_bin(CAST($4 AS integer) * INTERVAL '1 minute'", sql, StringComparison.Ordinal); + Assert.Contains(TrendBucketSql.OriginSql, sql, StringComparison.Ordinal); + Assert.Contains("GREATEST(date_bin(", sql, StringComparison.Ordinal); + Assert.Contains("MIN(collection_time) AS first_collection_time", sql, StringComparison.Ordinal); + Assert.Contains("COUNT(*) AS collection_count", sql, StringComparison.Ordinal); + Assert.Contains("HAVING COUNT(rated_reads) > 0", sql, StringComparison.Ordinal); + } + [Fact] public void TempDbFileIoTrendSql_ReadsColumnsThatExistInTheGeneratedFileIoTable() { @@ -208,9 +228,10 @@ public void NewViewerReads_PgDialect_PositionalParams_NoBareNow_NoNLiterals(stri /// naive UTC by the per-batch offset, and a UTC batch left untouched, both landing on collection_time); /// the tempdb usage read (MB-as-double and — the load-bearing check — the bigint /// total_sessions_using_tempdb read via GetInt64, which GetInt32 would throw on); and the tempdb -/// file-I/O read (tempdb-only filtering + per-file average-latency computation). Shares the serialized -/// "live-postgres" collection so the row churn can't race another class; uses negative sentinel -/// server_ids and cleans up in finally. +/// file-I/O read (tempdb-only filtering, per-file average-latency computation, and — since #4234 — the +/// same per-file bucket cap and singleton pass-through as the File I/O tab's own reads). Shares the +/// serialized "live-postgres" collection so the row churn can't race another class; uses negative +/// sentinel server_ids and cleans up in finally. /// [Collection("live-postgres")] public sealed class ViewerCpuTempDbLivePostgresTests @@ -230,6 +251,8 @@ public sealed class ViewerCpuTempDbLivePostgresTests private const int FileIoServerId = -969696; private const string FileIoServerName = "viewer-tempdb-fileio-e2e"; + private const int FileIoBudgetServerId = -969697; + [Fact] public async Task Cpu_ReadsRawSamplesInOrder_NullOtherAsZero_AgainstDevPostgres() { @@ -534,6 +557,10 @@ await LiveStoreCleanup.RunAsync(connectionString!, bodySucceeded, async (cleanup } } + /// #4234 ruling item 3: at a short window the budget covers every collection (one per file + /// here), so both reads return the raw points UNCHANGED, stamped at their own raw collection time + /// rather than the date_bin grid — proven by the exact-tick equality below, which the always-floors + /// pre-#4234 read also happened to satisfy only because it never bucketed at all. [Fact] public async Task TempDbFileIo_TempDbOnly_PerFileAverageLatency_AgainstDevPostgres() { @@ -561,10 +588,12 @@ await InsertFileIoAsync(connection, t1, "tempdb", "tempdb_log", await InsertFileIoAsync(connection, t1, "StackOverflow2010", "so_data", deltaReads: 1000, deltaWrites: 1000, deltaStallReadMs: 999999, deltaStallWriteMs: 999999); - var rows = await viewer.GetTempDbFileIoTrendAsync(FileIoServerId, t1.AddMinutes(-1)); + var rows = await viewer.GetTempDbFileIoTrendAsync(FileIoServerId, t1.AddMinutes(-1), t1.AddMinutes(1)); /* Only the two tempdb files survive the database_name = 'tempdb' filter. */ Assert.Equal(2, rows.Count); + /* #4234: one collection per file over the whole window — the budget covers it, so every + point is stamped at its own raw collection_time, not a bucket_start grid line. */ Assert.All(rows, r => Assert.Equal(t1.Ticks, r.CollectionTime.Ticks)); /* Ordered by file_name. */ Assert.Equal(new[] { "tempdb_data", "tempdb_log" }, rows.Select(r => r.FileName)); @@ -585,6 +614,51 @@ await LiveStoreCleanup.RunAsync(connectionString!, bodySucceeded, async (cleanup } } + /// #4234 ruling items 2/6: a 7-day window at the real 1-minute file_io_stats cadence used to + /// return every collection per file — now capped to 's point budget PER + /// FILE, exactly like the File I/O tab's own per-file reads. Bulk-seeded server-side (generate_series) + /// rather than one round trip per minute. + [Fact] + public async Task TempDbFileIo_SevenDayWindow_ReturnsAtMostBudgetTimesFileCount_AgainstDevPostgres() + { + var connectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + Assert.SkipWhen(string.IsNullOrEmpty(connectionString), + "Set DARLING_TEST_PG to a Postgres connection string to run the live tempdb-file-I/O budget-cap test."); + + using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(TestContext.Current.CancellationToken); + await PgMigrations.MigrateAsync(connection, TestContext.Current.CancellationToken); + await DeleteRowsAsync(connection, "file_io_stats", FileIoBudgetServerId, TestContext.Current.CancellationToken); + + await using var viewer = new ViewerDataService(connectionString!); + + var bodySucceeded = false; + try + { + var end = new DateTime(2026, 3, 10, 0, 0, 0); + var start = end.AddDays(-7); + var files = new[] { "tempdb_data", "tempdb_data2", "tempdb_log", "tempdb_data3" }; + var baseId = CollectionIdGenerator.Next() * 1_000_000L; + foreach (var file in files) + { + await BulkSeedTempDbFileIoAsync(connection, TestContext.Current.CancellationToken, baseId, FileIoBudgetServerId, start, end, file); + baseId += 20_000; + } + + var rows = await viewer.GetTempDbFileIoTrendAsync(FileIoBudgetServerId, start, end); + var budget = TrendBudget.Chart.AutoPoints * files.Length; + + Assert.True(rows.Count > 0 && rows.Count <= budget, $"tempdb file I/O: {rows.Count} rows over a {files.Length}-file budget of {budget}"); + + bodySucceeded = true; + } + finally + { + await LiveStoreCleanup.RunAsync(connectionString!, bodySucceeded, async (cleanup, cleanupCt) => + await DeleteRowsAsync(cleanup, "file_io_stats", FileIoBudgetServerId, cleanupCt)); + } + } + private static async Task InsertCpuAsync( NpgsqlConnection connection, int serverId, string serverName, DateTime collectionTimeUtc, DateTime sampleTimeLocal, int sqlCpu, int? otherCpu) @@ -677,6 +751,30 @@ INSERT INTO file_io_stats await command.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); } + /// One tempdb file_io_stats collection per minute from to + /// inclusive, generated server-side — the real cadence a 7-day tempdb-file-I/O + /// window would see — without a per-row round trip. Mirrors ViewerFileIoBlockingTests's + /// BulkSeedFileIoAsync, database_name fixed to 'tempdb'. + private static async Task BulkSeedTempDbFileIoAsync( + NpgsqlConnection connection, System.Threading.CancellationToken ct, long baseId, int serverId, + DateTime start, DateTime end, string fileName) + { + using var command = new NpgsqlCommand(@" +INSERT INTO file_io_stats + (collection_id, collection_time, server_id, server_name, + database_name, file_name, delta_reads, delta_writes, + delta_stall_read_ms, delta_stall_write_ms) +SELECT $1 + row_number() OVER (), g, $2, $3, 'tempdb', $4, 10, 4, 100, 20 +FROM generate_series($5::timestamp, $6::timestamp, interval '1 minute') AS g", connection); + command.Parameters.AddWithValue(baseId); + command.Parameters.AddWithValue(serverId); + command.Parameters.AddWithValue(FileIoServerName); + command.Parameters.AddWithValue(fileName); + command.Parameters.AddWithValue(DateTime.SpecifyKind(start, DateTimeKind.Unspecified)); + command.Parameters.AddWithValue(DateTime.SpecifyKind(end, DateTimeKind.Unspecified)); + await command.ExecuteNonQueryAsync(ct); + } + private static DateTime TruncateToSeconds(DateTime value) => DateTime.SpecifyKind(new DateTime(value.Ticks - (value.Ticks % TimeSpan.TicksPerSecond)), DateTimeKind.Unspecified); diff --git a/Darling/Darling.Tests/ViewerFileIoBlockingTests.cs b/Darling/Darling.Tests/ViewerFileIoBlockingTests.cs index 0bbc78b41d..0b6526f5f9 100644 --- a/Darling/Darling.Tests/ViewerFileIoBlockingTests.cs +++ b/Darling/Darling.Tests/ViewerFileIoBlockingTests.cs @@ -146,14 +146,16 @@ public void LockWaitTrendSql_FiltersLckPrefix_PerSecondRateViaLag() Assert.DoesNotContain("ELSE 0", ViewerDataService.LockWaitTrendSql, StringComparison.Ordinal); } - /// #3540: the latency reads drop rows whose stored interval is 0 — the calculator's "no delta - /// knowable" marker — so a restart renders as an absent point, never "0.00 ms". IS DISTINCT FROM 0 keeps - /// pre-V127 rows (NULL). Both the File I/O tab's read and the tempdb tab's file read. + /// #3540: the latency reads null out rows whose stored interval is 0 — the calculator's "no + /// delta knowable" marker — so a restart renders as an absent point, never "0.00 ms". IS DISTINCT FROM 0 + /// keeps pre-V127 rows (NULL). Both the File I/O tab's read and the tempdb tab's file read (#4234: + /// bucketed, so both null the marker out of a rated CTE rather than filtering the row out of the + /// FROM clause — the row still counts toward collection_count). [Fact] public void FileIoLatencyReads_DropTheUnknowableMarker_KeepPreV127Rows() { Assert.Contains("CASE WHEN f.sample_interval_seconds IS DISTINCT FROM 0 THEN f.delta_reads END AS rated_reads", ViewerDataService.FileIoLatencyTrendSql, StringComparison.Ordinal); - Assert.Contains("AND sample_interval_seconds IS DISTINCT FROM 0", ViewerDataService.TempDbFileIoTrendSql, StringComparison.Ordinal); + Assert.Contains("CASE WHEN sample_interval_seconds IS DISTINCT FROM 0 THEN delta_reads END AS rated_reads", ViewerDataService.TempDbFileIoTrendSql, StringComparison.Ordinal); } [Fact] diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.TempDb.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.TempDb.cs index 14fb898963..5f5fc05b5a 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.TempDb.cs +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.TempDb.cs @@ -12,6 +12,9 @@ using System.Threading.Tasks; using Npgsql; +using PerformanceMonitor.Common; +using PerformanceMonitor.Darling.Storage; + namespace PerformanceMonitor.Darling.Viewer; /// @@ -34,9 +37,13 @@ public sealed record TempDbSample( double TopSessionTempDbMb); /// -/// One tempdb file's I/O-latency point — a collection time, the file name, and the read/write average -/// latencies (ms) for that collection. Mirror of Lite's FileIoTrendPoint as the tempdb file-I/O -/// chart consumes it; the chart sums read+write latency per file and cycles a color per file. +/// One tempdb file's I/O-latency point — a bucket (or, for a singleton bucket, a raw collection) time, +/// the file name, and the read/write average latencies (ms). Mirror of Lite's FileIoTrendPoint as +/// the tempdb file-I/O chart consumes it; the chart sums read+write latency per file and cycles a color +/// per file. +/// #4234: CollectionTime is a date_bin bucket start, EXCEPT when every bucket the call +/// returned holds exactly one physical collection, in which case every point is stamped at its own raw +/// collection time instead — see . /// public sealed record TempDbFileIoSample( DateTime CollectionTime, @@ -73,32 +80,52 @@ ORDER BY collection_time """; /// - /// The tempdb per-file I/O-latency read — Lite's GetTempDbFileIoTrendAsync ported to - /// Postgres. Reads v_file_io_stats (a SELECT * passthrough over file_io_stats) - /// to mirror Lite's view-based query, filtered to tempdb, and computes per-collection average - /// read/write latency (stall ms / operation count) with the delta-stall sums CAST to double - /// precision before division. Grouped by (collection_time, file_name) so each tempdb data file is - /// its own series. Rows whose stored sample_interval_seconds is 0 — no delta knowable, a - /// restart — are dropped rather than rendered as 0.00 ms (#3540). - /// $1 server_id, $2 window start (naive UTC). + /// The tempdb per-file I/O-latency read — Lite's GetTempDbFileIoTrendAsync ported to Postgres, + /// then bucketed (#4234), matching Lite's own bucketed twin (LocalDataService.FileIo.cs, + /// #4340) column-for-column. Reads v_file_io_stats (a SELECT * passthrough over + /// file_io_stats) to mirror Lite's view-based query, filtered to tempdb, and computes average + /// read/write latency per bucket (stall ms / operation count) with the delta-stall sums CAST to + /// double precision before division. Grouped by file_name so each tempdb data file is its own series + /// — no top-10 ranking pass, unlike , since tempdb's own file count + /// is already small. + /// #3540, carried into the bucket exactly like : a row whose + /// stored sample_interval_seconds is 0 (no delta knowable — first sighting, counter reset, a + /// gap past the policy) has its deltas nulled out of the sums via the rated CTE rather than + /// contributing a confident 0, exactly as the pre-bucket read dropped the row outright. IS DISTINCT + /// FROM 0 still keeps pre-V127 rows (NULL: interval never recorded). The row is NOT removed from + /// the FROM clause — it still counts toward collection_count below, so a bucket is never + /// mistaken for a true singleton just because its one physical collection happened to be unrated. + /// HAVING COUNT(rated_reads) > 0 drops a bucket whose every row was unrated, matching the + /// pre-bucket read's "absent, not 0.00 ms" answer for that case. + /// $1 server_id, $2 window start, $3 window end (naive UTC), $4 bucket width minutes. /// - public const string TempDbFileIoTrendSql = """ + public const string TempDbFileIoTrendSql = $$""" + WITH rated AS ( + SELECT + file_name, + collection_time, + /* #3540: see FileIoLatencyTrendSql's rated CTE for why this nulls rather than filters. */ + CASE WHEN sample_interval_seconds IS DISTINCT FROM 0 THEN delta_reads END AS rated_reads, + CASE WHEN sample_interval_seconds IS DISTINCT FROM 0 THEN delta_writes END AS rated_writes, + CASE WHEN sample_interval_seconds IS DISTINCT FROM 0 THEN CAST(delta_stall_read_ms AS double precision) END AS rated_stall_read_ms, + CASE WHEN sample_interval_seconds IS DISTINCT FROM 0 THEN CAST(delta_stall_write_ms AS double precision) END AS rated_stall_write_ms + FROM v_file_io_stats + WHERE server_id = $1 + AND collection_time >= $2 + AND collection_time <= $3 + AND database_name = 'tempdb' + ) SELECT - collection_time, file_name, - CASE WHEN SUM(delta_reads) > 0 THEN SUM(CAST(delta_stall_read_ms AS double precision)) / SUM(delta_reads) ELSE 0 END AS avg_read_latency_ms, - CASE WHEN SUM(delta_writes) > 0 THEN SUM(CAST(delta_stall_write_ms AS double precision)) / SUM(delta_writes) ELSE 0 END AS avg_write_latency_ms - FROM v_file_io_stats - WHERE server_id = $1 - AND collection_time >= $2 - AND database_name = 'tempdb' - /* #3540: a stored interval of 0 is the calculator's "no delta knowable" marker (first sighting, - counter reset, a gap past the policy) — the row is dropped so the point is ABSENT rather than the - confident "0.00 ms" a restart used to render. IS DISTINCT FROM 0 keeps pre-V127 rows (NULL: interval - never recorded), which carry on reading exactly as they always did. */ - AND sample_interval_seconds IS DISTINCT FROM 0 - GROUP BY collection_time, file_name - ORDER BY collection_time, file_name + GREATEST(date_bin(CAST($4 AS integer) * INTERVAL '1 minute', collection_time, {{TrendBucketSql.OriginSql}}), $2) AS bucket_start, + CASE WHEN SUM(rated_reads) > 0 THEN SUM(rated_stall_read_ms) / SUM(rated_reads) ELSE 0 END AS avg_read_latency_ms, + CASE WHEN SUM(rated_writes) > 0 THEN SUM(rated_stall_write_ms) / SUM(rated_writes) ELSE 0 END AS avg_write_latency_ms, + MIN(collection_time) AS first_collection_time, + COUNT(*) AS collection_count + FROM rated + GROUP BY file_name, 2 + HAVING COUNT(rated_reads) > 0 + ORDER BY file_name, 2 """; /// @@ -136,30 +163,62 @@ public async Task> GetTempDbTrendAsync(int serverId, DateTime } /// - /// tempdb per-file I/O-latency points for one server since , - /// time- then file-ordered, for the tempdb tab's file-I/O chart. + /// tempdb per-file I/O-latency points for one server over the window, file- then bucket-ordered, for + /// the tempdb tab's file-I/O chart. + /// #4234: buckets to 's point budget, exactly like + /// — see its remarks for the width and singleton-stamping + /// rules, which this read shares verbatim ( replaces the old start-only + /// window so the SQL can bucket on a known width). /// - public async Task> GetTempDbFileIoTrendAsync(int serverId, DateTime sinceUtc, CancellationToken cancellationToken = default) + public async Task> GetTempDbFileIoTrendAsync( + int serverId, DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken = default) { var samples = new List(); + var windowMinutes = Math.Max(1, (int)Math.Ceiling((endUtc - startUtc).TotalMinutes)); + var bucketMinutes = TrendBuckets.AutoMinutes(windowMinutes, 1, TrendBudget.Chart.AutoPoints); + await using var command = _dataSource.CreateCommand(TempDbFileIoTrendSql); command.CommandTimeout = ViewerCommandDeadlines.CurrentInteractiveReadSeconds; command.Parameters.Add(new NpgsqlParameter { TypedValue = serverId }); command.Parameters.Add(new NpgsqlParameter { - TypedValue = DateTime.SpecifyKind(sinceUtc, DateTimeKind.Unspecified), + TypedValue = DateTime.SpecifyKind(startUtc, DateTimeKind.Unspecified), + }); + command.Parameters.Add(new NpgsqlParameter + { + TypedValue = DateTime.SpecifyKind(endUtc, DateTimeKind.Unspecified), }); + command.Parameters.Add(new NpgsqlParameter { TypedValue = bucketMinutes }); + + var rows = new List<(string FileName, DateTime BucketStart, DateTime FirstCollectionTime, double AvgRead, double AvgWrite)>(); + var everyBucketSingleton = true; + await using var reader = await command.ExecuteReaderAsync(cancellationToken); while (await reader.ReadAsync(cancellationToken)) { - samples.Add(new TempDbFileIoSample( - reader.GetDateTime(0), - reader.IsDBNull(1) ? "" : reader.GetString(1), + if (reader.GetInt64(5) != 1) + { + everyBucketSingleton = false; + } + + rows.Add(( + reader.IsDBNull(0) ? "" : reader.GetString(0), + reader.GetDateTime(1), + reader.GetDateTime(4), reader.IsDBNull(2) ? 0 : reader.GetDouble(2), reader.IsDBNull(3) ? 0 : reader.GetDouble(3))); } + foreach (var row in rows) + { + samples.Add(new TempDbFileIoSample( + everyBucketSingleton ? row.FirstCollectionTime : row.BucketStart, + row.FileName, + row.AvgRead, + row.AvgWrite)); + } + return samples; } } diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Charts.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Charts.cs index 66dfcbb3ea..6579100497 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Charts.cs +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Charts.cs @@ -121,15 +121,15 @@ private async Task LoadTempDbAsync() /* Both reads run concurrently — NpgsqlDataSource pools a connection for each. */ using var readFanOut = ViewerReadFanOut.Of(2); var trendTask = _dataService.GetTempDbTrendAsync(_server.ServerId, startUtc); - var fileIoTask = _dataService.GetTempDbFileIoTrendAsync(_server.ServerId, startUtc); + var fileIoTask = _dataService.GetTempDbFileIoTrendAsync(_server.ServerId, startUtc, endUtc); var trend = await trendTask; var fileIo = await fileIoTask; - /* Start-only reads; bound the end for a custom range (collection_time is naive UTC). */ + /* trend is a start-only read; bound the end for a custom range (collection_time is naive UTC). + fileIo is bucketed (#4234) and already windowed server-side on endUtc. */ if (IsCustomRange) { trend = trend.Where(t => t.CollectionTime <= endUtc).ToList(); - fileIo = fileIo.Where(f => f.CollectionTime <= endUtc).ToList(); } RenderTempDbUsageChart(trend, startUtc, endUtc);