Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
1b90d30
issue-3653 A6 LA step 1: RollupCoverage.StitchedRelationSql, pure tests
erikdarlingdata Sep 24, 2026
307482e
issue-3653 A6 LA step 1 (partial): route FinOps workload readers thro…
erikdarlingdata Sep 24, 2026
9259215
issue-3653 A6 LA-3a step 1: route Compose hourly reads through Stitch…
erikdarlingdata Sep 24, 2026
9ad5537
issue-3653 A6 LA-3a step 2: route ViewerDataService.QueryTrends throu…
erikdarlingdata Sep 24, 2026
e243568
issue-3653 A6 LA-3a step 3: route get_query_trend's hourly relation t…
erikdarlingdata Sep 24, 2026
b6ac4c3
issue-3653 A6 LA-3a step 4: source-scan test for decision 5 (Explicit…
erikdarlingdata Sep 24, 2026
1b718a2
issue-3653 A6 LA-3b1 step 1 (partial): DurationTrendRoute gains Hourl…
erikdarlingdata Sep 24, 2026
7ca81a7
issue-3653 A6 LA-3b2: stitch DailySummarySql's hourly source and not-…
erikdarlingdata Sep 24, 2026
9c76f2b
issue-3653 A6 LA-4b step 1: pure tests for DailySummarySql's stitched…
erikdarlingdata Sep 24, 2026
c46257a
issue-3653 A6 LA-4b steps 2-3: live proof, one stitched hourly pair r…
erikdarlingdata Sep 24, 2026
f5aef65
issue-3653 A6 LA-4a steps 1-2: splice the hourly stitch into DarlingT…
erikdarlingdata Sep 24, 2026
0fd0b71
issue-3653 A6 LA-4a step 3: pure test for the stitched hourly builder…
erikdarlingdata Sep 24, 2026
504db3b
issue-3653 A6 LA-5: wire windowEndUtc live, flip the scan test, fix t…
erikdarlingdata Sep 24, 2026
e2e007f
issue-3653 A6 LA-6: route the SQL splice through StitchedRelationSql,…
erikdarlingdata Sep 24, 2026
0ec6ba5
issue-3653 A6 LA-7: day-align the daily-summary stitch boundary, fix …
erikdarlingdata Sep 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
366 changes: 366 additions & 0 deletions Darling/Darling.Tests/DailySummaryAndComposeStitchedLiveTests.cs

Large diffs are not rendered by default.

26 changes: 15 additions & 11 deletions Darling/Darling.Tests/DailySummaryNotCarriedTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -379,23 +379,27 @@ so a carried day reads exactly one source (its queries row) and the skipped day
Assert.Equal(2, days[3].GetProperty("unique_queries").GetInt32());
}

/* And the recent server's window through the same reader: ten days back routes HOURLY by age (inside
HourlyMaxAge, past RawMaxAge), and the supply rule keeps the LEGACY — the successor's oldest bucket is
the 01:00 collection, an hour past the window's midnight start, so it does not reach as far as the
legacy does and "loses nothing" fails by that hour (PrefersSuccessor's own bar; asserted through the
probe the reader uses, not assumed). So the reader carries the legacy's answer: the restart-only D1 is
the legacy's hole and reads NULL, D2 is carried, and days_missing names D1 — the hole scan's rule
surfacing on the wire for a day whose only row is one the successor would have refused. */
/* And the recent server's window through the same reader, since #3653 A6, lane LA-3b2: ten days back
routes HOURLY by age (inside HourlyMaxAge, past RawMaxAge), and the read now STITCHES the legacy and
the successor at the successor's floor F, day-aligned (this PR's own fix) — not the pre-A6 supply
rule this leg used to assert, which carried the legacy's answer whole. In this seed the successor's
oldest bucket is R(0) 01:00, so F falls inside R(0) and the day-aligned boundary is R(1): the legacy
alone supplies R(0), and R(1) onward reads through the successor's own probe. D1 (R(1), the control,
an empty day) is genuinely absent — its only row is the restart row (interval 0), which the
successor's WHERE refuses, and its own not-carried probe carries the same filter, so a day with no
admitted row is not named a hole. D2 (R(2)) is the daily rollup's skip carried up through the hourly
successor's own hole: NULL, and named. D3 and D4 read their planted counts. */
var coverage = await TimescaleSupport.DetectRollupCoverageAsync(postgres, await TimescaleSupport.DetectRollupsAsync(postgres, ct), ct);
Assert.Equal(hourly, coverage.HourlyRelationFor(hourly, R(0)));
Assert.True(coverage.StitchFloor(hourly, RollupCoverage.StitchTier.Hourly, R(0)).HasValue, "the stitch must apply over this window (LA-3 decision 4) for the assertions below to hold");
var recentRange = await DarlingHealthReader.GetDailySummaryRangeAsync(postgres, RecentServerId, R(0), R(5), cancellationToken: ct);
Assert.Equal(new[] { R(0), R(1), R(2), R(3), R(4) }, recentRange.Rows.Select(r => r.SummaryDate).ToArray());
Assert.Equal(new long?[] { 3L, null, 5L, 7L, 2L }, recentRange.Rows.Select(r => r.UniqueQueries).ToArray());
Assert.Equal(new[] { R(1) }, recentRange.DaysMissing);
Assert.Equal(new[] { R(0), R(2), R(3), R(4) }, recentRange.Rows.Select(r => r.SummaryDate).ToArray());
Assert.Equal(new long?[] { 3L, null, 7L, 2L }, recentRange.Rows.Select(r => r.UniqueQueries).ToArray());
Assert.Equal(new[] { R(2) }, recentRange.DaysMissing);
var recentWire = await DarlingMcpHealthTools.GetDailySummaryRange(postgres, RecentServerName, days_back: 5, as_of: R(4).ToString("yyyy-MM-dd", CultureInfo.InvariantCulture));
using (var doc = JsonDocument.Parse(recentWire))
{
Assert.Equal(new[] { R(1).ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) }, doc.RootElement.GetProperty("days_missing").EnumerateArray().Select(e => e.GetString()).ToArray());
Assert.Equal(new[] { R(2).ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) }, doc.RootElement.GetProperty("days_missing").EnumerateArray().Select(e => e.GetString()).ToArray());
}

var single = await DarlingMcpHealthTools.GetDailySummary(postgres, OldServerName, O(2).ToString("yyyy-MM-dd", CultureInfo.InvariantCulture));
Expand Down
148 changes: 148 additions & 0 deletions Darling/Darling.Tests/DailySummaryStitchedRangeTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
/*
* Copyright (c) 2026 Erik Darling, Darling Data LLC
*
* This file is part of the SQL Server Performance Monitor.
*
* Licensed under the MIT License. See LICENSE file in the project root for full license information.
*/

using System;
using System.Collections.Generic;
using PerformanceMonitor.Darling.Storage;
using Xunit;

namespace Darling.Tests;

/// <summary>
/// #3653 A6, lane LA-4b: pure pins for <see cref="DailySummarySql.RangeSqlFor(RetentionTier, RollupCoverage, DateTime)"/>,
/// the three-argument, stitch-aware overload LA-3b2 added (commit e708093a4). No successor, or a successor that
/// does not straddle the window, must be byte-identical to today's single-probe text; a genuinely stitched pair
/// must UNION ALL the not-carried probe once per side of F, split exactly where
/// <see cref="RollupCoverage.StitchedRelationSql"/> would split the FROM clause (decision 3).
/// </summary>
public sealed class DailySummaryStitchedRangeTests
{
private static readonly DateTime Now = new(2026, 9, 24, 12, 0, 0, DateTimeKind.Utc);

private static DateTime DaysAgo(double days) => Now.AddDays(-days);

private const string Legacy = TimescaleSupport.QueryStatsHourlyView;
private const string Successor = TimescaleSupport.QueryStatsIntervalHourlyView;

[Fact]
public void NoSuccessor_IsByteIdenticalToTheSingleProbeForm()
{
var coverage = new RollupCoverage(
new Dictionary<string, DateTime>(StringComparer.Ordinal) { [Legacy] = DaysAgo(80) },
new Dictionary<string, DateTime>(StringComparer.Ordinal),
RollupAvailability.WithoutIntervalHourlies);

var windowStart = DaysAgo(10);
var stitched = DailySummarySql.RangeSqlFor(RetentionTier.Hourly, coverage, windowStart);
var single = DailySummarySql.RangeSqlFor(RetentionTier.Hourly, coverage.HourlyRelationFor(Legacy, windowStart));

Assert.Equal(single, stitched);

/* The single-probe shape has exactly one "queries_ceiling" CTE (not the stitched pair's two, split by
legacy/successor), and no per-side boundary literal. */
Assert.Contains("queries_ceiling AS (", stitched, StringComparison.Ordinal);
Assert.DoesNotContain("queries_ceiling_legacy", stitched, StringComparison.Ordinal);
Assert.DoesNotContain("queries_ceiling_successor", stitched, StringComparison.Ordinal);
}

[Fact]
public void SuccessorReachesBeforeWindowStart_RoutesLegacyOnly_ByteIdenticalToday()
{
/* The successor's floor already covers the whole window (StitchFloor answers null here too) — the
legacy-only, single-probe form, but the RELATION resolved is the SUCCESSOR (HourlyRelationFor's own
answer for this shape), matching what StitchedRelationSql would splice as successor-only. */
var successorFloor = DaysAgo(80);
var coverage = new RollupCoverage(
new Dictionary<string, DateTime>(StringComparer.Ordinal) { [Legacy] = DaysAgo(90), [Successor] = successorFloor },
new Dictionary<string, DateTime>(StringComparer.Ordinal),
RollupAvailability.All);

var windowStart = DaysAgo(10);
var stitched = DailySummarySql.RangeSqlFor(RetentionTier.Hourly, coverage, windowStart);
var single = DailySummarySql.RangeSqlFor(RetentionTier.Hourly, coverage.HourlyRelationFor(Legacy, windowStart));

Assert.Equal(single, stitched);
Assert.Contains($"FROM collect.{Successor}", stitched, StringComparison.Ordinal);
Assert.DoesNotContain(Legacy, stitched, StringComparison.Ordinal);
}

[Fact]
public void StitchedPair_SplitsTheNotCarriedProbeOnceEachSide_AtTheSameBoundaryTheReadSplits()
{
var successorFloor = DaysAgo(5);
var coverage = new RollupCoverage(
new Dictionary<string, DateTime>(StringComparer.Ordinal) { [Legacy] = DaysAgo(80), [Successor] = successorFloor },
new Dictionary<string, DateTime>(StringComparer.Ordinal),
RollupAvailability.All);

var windowStart = DaysAgo(10);
var sql = DailySummarySql.RangeSqlFor(RetentionTier.Hourly, coverage, windowStart);

/* successorFloor lands mid-day (Now is 12:00:00), so the boundary literal actually spliced into the SQL
is DAY-ALIGNED: the first whole day at or after F, never F's own hour (the fix for #4182's CI failure
— see DailySummarySql.QueriesCteForStitchedCagg's remarks). */
var boundaryDay = successorFloor.Date.AddDays(1);
var literal = $"TIMESTAMP '{boundaryDay:yyyy-MM-dd HH:mm:ss.ffffff}'";

/* The two rollup-half members, one per relation, each restricted to its own side of F. Matched by
substring on the individually meaningful fragments rather than a whole multi-line block, so the
assertion survives either line-ending convention. */
Assert.Contains($"FROM collect.{Legacy}", sql, StringComparison.Ordinal);
Assert.Contains($"FROM collect.{Successor}", sql, StringComparison.Ordinal);
Assert.Contains($"bucket >= $2 AND bucket < $3 AND bucket < {literal}", sql, StringComparison.Ordinal);
Assert.Contains($"bucket >= $2 AND bucket < $3 AND bucket >= {literal}", sql, StringComparison.Ordinal);

/* The not-carried ("NOT EXISTS") probe appears TWICE — once per relation name, decision 3 — each
restricted to its side of F, and each keeps its own ceiling. */
Assert.Equal(2, System.Text.RegularExpressions.Regex.Matches(sql, "NOT EXISTS").Count);
Assert.Contains($"SELECT 1 FROM collect.{Legacy} AS r", sql, StringComparison.Ordinal);
Assert.Contains($"SELECT 1 FROM collect.{Successor} AS r", sql, StringComparison.Ordinal);
Assert.Contains("queries_ceiling_legacy", sql, StringComparison.Ordinal);
Assert.Contains("queries_ceiling_successor", sql, StringComparison.Ordinal);
Assert.Contains($"WHERE server_id = $1 AND bucket < {literal}", sql, StringComparison.Ordinal);
Assert.Contains($"WHERE server_id = $1 AND bucket >= {literal}", sql, StringComparison.Ordinal);

/* StitchedRelationSql's own FROM-clause split stays at F's actual hour (every row is examined on its
own there, with no GROUP BY day, so a mid-day split costs it nothing); this probe instead needs the
DAY F falls in, because its CTE groups by date_trunc('day', bucket) and a mid-day split would hand
one calendar day two partial rows — the #4182 bug this fix corrects. So the two must agree only that
this probe's boundary DAY is the first whole day at or after StitchedRelationSql's own F, not that
the literals are identical. */
var fromClause = coverage.StitchedRelationSql(Legacy, "f", windowStart, RollupCoverage.StitchTier.Hourly);
Assert.Contains($"TIMESTAMP '{successorFloor:yyyy-MM-dd HH:mm:ss.ffffff}'", fromClause, StringComparison.Ordinal);
Assert.Equal(boundaryDay, successorFloor.Date.AddDays(1));
}

[Fact]
public void DailyTier_IgnoresTheStitch_ReadsExactlyAsTheTwoArgumentFormDoes()
{
var coverage = new RollupCoverage(
new Dictionary<string, DateTime>(StringComparer.Ordinal) { [Legacy] = DaysAgo(80), [Successor] = DaysAgo(5) },
new Dictionary<string, DateTime>(StringComparer.Ordinal),
RollupAvailability.All);

var stitched = DailySummarySql.RangeSqlFor(RetentionTier.Daily, coverage, DaysAgo(10));
var plain = DailySummarySql.RangeSqlFor(RetentionTier.Daily);

Assert.Equal(plain, stitched);
}

[Fact]
public void RawTier_IgnoresTheStitch_ReadsExactlyAsTheTwoArgumentFormDoes()
{
var coverage = new RollupCoverage(
new Dictionary<string, DateTime>(StringComparer.Ordinal) { [Legacy] = DaysAgo(80), [Successor] = DaysAgo(5) },
new Dictionary<string, DateTime>(StringComparer.Ordinal),
RollupAvailability.All);

var stitched = DailySummarySql.RangeSqlFor(RetentionTier.Raw, coverage, DaysAgo(10));
var plain = DailySummarySql.RangeSqlFor(RetentionTier.Raw);

Assert.Equal(plain, stitched);
}
}
4 changes: 3 additions & 1 deletion Darling/Darling.Tests/DarlingMcpTrendToolsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -554,7 +554,9 @@ LAG over collection_time (the Query Store read LAGs over point_time), no NULLIF
so the hourly constants stay as the pinned legacy text and are no longer the routed read's argument. */
Assert.Contains("QueryDurationTrendSql, postgres, serverId, startUtc, endUtc, route, bucketMinutes, cancellationToken", reader, StringComparison.Ordinal);
Assert.Contains("ProcedureDurationTrendSql, postgres, serverId, startUtc, endUtc, route, bucketMinutes, cancellationToken", reader, StringComparison.Ordinal);
Assert.Contains("DurationTrendRouting.BuildBucketedHourlyTrendSql(route.HourlyView)", reader, StringComparison.Ordinal);
// LA-4a routed the builder call off the stitched from-clause instead of the bare view name, so the pin
// follows the builder call site, not the old constant.
Assert.Contains("DurationTrendRouting.BuildBucketedHourlyTrendSql(route.HourlyFromClauseOrDefault)", reader, StringComparison.Ordinal);
}

private static string Lf(string s) => s.Replace("\r\n", "\n", StringComparison.Ordinal);
Expand Down
Loading
Loading