diff --git a/Darling/Darling.Tests/CollectionLogWatermarkAndJobHistoryIndexesRungTests.cs b/Darling/Darling.Tests/CollectionLogWatermarkAndJobHistoryIndexesRungTests.cs new file mode 100644 index 000000000..b98414d6e --- /dev/null +++ b/Darling/Darling.Tests/CollectionLogWatermarkAndJobHistoryIndexesRungTests.cs @@ -0,0 +1,151 @@ +/* + * 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.Linq; +using System.Threading.Tasks; +using Npgsql; +using PerformanceMonitor.Darling.Storage; +using PerformanceMonitor.Darling.Viewer; +using Xunit; + +namespace Darling.Tests; + +/// +/// Pins Darling rung V150 (#4469, #4477): two supporting indexes, idx_collection_log_watermark on +/// collect.collection_log (server_id, collector_name, collection_time DESC) for the per-collector +/// watermark lookup, and idx_job_history_server_run on collect.job_history (server_id, +/// run_datetime DESC, instance_id DESC) for the Viewer's Job History tab. This file is the RUNG +/// (ladder, viewer probe) and the schema-after-migrate proof: both indexes exist, plain +/// CREATE INDEX IF NOT EXISTS, idempotent on rerun. +/// +/// This file's "I am the top rung" claim takes over from QueryStoreLivenessHotTouchLiveTests +/// (V149) now that V150 has landed. +/// +/* #1776 own-store: each fact mints its own scratch database through ScratchPostgres and never touches the + shared store's tables, so it cannot race the live collection and serializing it would be pure slowdown. */ +public sealed class CollectionLogWatermarkAndJobHistoryIndexesRungTests +{ + private const int RungVersion = 150; + private const int PreviousVersion = 149; + + /// This rung's sentinel ordinal in the viewer probe — the newest, so the last argument. + private const int ProbeOrdinal = 125; + + private static string? ConnectionString => Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + + /// + /// The rung is registered and is the new top of the ladder — the claim this class takes over from + /// QueryStoreLivenessHotTouchLiveTests (V149) now that V150 has landed. + /// + [Fact] + public void TheRungIsRegisteredAtTheTopOfADenseLadder() + { + var versions = PgMigrations.Scripts.Select(s => s.Version).ToList(); + + Assert.Equal("collection-log-watermark-and-job-history-indexes", PgMigrations.Scripts.Single(s => s.Version == RungVersion).Name); + Assert.Equal(StorageVersion.SchemaVersion, PgMigrations.Scripts[^1].Version); + Assert.Equal(StorageVersion.SchemaVersion, versions.Max()); + Assert.Equal(RungVersion, StorageVersion.SchemaVersion); + Assert.Equal(versions.Distinct().OrderBy(v => v), versions); + } + + /// + /// The viewer probe's sentinel carries this rung, and the map treats it as the TOP arm: a missing top arm + /// maps a fully-migrated store one rung short, permanently, because + /// is . + /// + [Fact] + public void TheProbeMapsAFullyMigratedStoreToThisTopRung() + { + var probe = ViewerDataService.StoreSchemaProbeSql.Replace("\r\n", "\n", StringComparison.Ordinal); + Assert.Contains("idx_collection_log_watermark", probe, StringComparison.Ordinal); + Assert.Contains("idx_job_history_server_run", probe, StringComparison.Ordinal); + + var viewer = RepoFile.ReadRepoFile("Darling", "PerformanceMonitor.Darling.Viewer", "ViewerDataService.cs"); + Assert.Contains($"reader.GetBoolean({ProbeOrdinal})", viewer, StringComparison.Ordinal); + Assert.DoesNotContain($"reader.GetBoolean({ProbeOrdinal + 1})", viewer, StringComparison.Ordinal); + + Assert.Equal(StorageVersion.SchemaVersion, ViewerDataService.RequiredStoreSchemaVersion); + + var method = typeof(ViewerDataService).GetMethod("MapProbedSchemaVersion", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static)!; + var arity = method.GetParameters().Length; + Assert.Equal(ProbeOrdinal, arity - 1); + Assert.Equal("hasCollectionLogWatermarkAndJobHistoryIndexes", method.GetParameters()[ProbeOrdinal].Name); + + var all = Enumerable.Repeat((object)true, arity).ToArray(); + Assert.Equal(StorageVersion.SchemaVersion, (int)method.Invoke(null, all)!); + + var behind = (object[])all.Clone(); + behind[ProbeOrdinal] = false; + Assert.Equal(PreviousVersion, (int)method.Invoke(null, behind)!); + + var thisArm = viewer.IndexOf("if (hasCollectionLogWatermarkAndJobHistoryIndexes)", StringComparison.Ordinal); + var previousArm = viewer.IndexOf("if (hasHotLivenessTouch)", StringComparison.Ordinal); + Assert.True(thisArm >= 0, "the viewer has no V150 sentinel arm — a fully-migrated store would map one rung short"); + Assert.True(thisArm < previousArm, "the V150 arm sits below V149's, so a current store maps one rung short"); + Assert.Contains( + "return " + StorageVersion.SchemaVersion.ToString(System.Globalization.CultureInfo.InvariantCulture) + ";", + viewer[thisArm..previousArm], StringComparison.Ordinal); + } + + /// + /// The LIVE schema after migrate: both indexes exist. Run against origin/dev (pre-V150) this is + /// RED — neither index exists — proving the pin actually checks the rung rather than a tautology. + /// + [Fact] + public async Task AfterMigrate_BothIndexesExist() + { + var baseConnectionString = ConnectionString; + Assert.SkipWhen(string.IsNullOrEmpty(baseConnectionString), + "Set DARLING_TEST_PG to a Postgres connection string to run the V150 schema pin."); + + var ct = TestContext.Current.CancellationToken; + + await using var scratch = await ScratchPostgres.CreateAsync(baseConnectionString!, ct); + await using var connection = new NpgsqlConnection(scratch.ConnectionString); + await connection.OpenAsync(ct); + await PgMigrations.MigrateAsync(connection, ct); + + Assert.True(await IndexExistsAsync(connection, "idx_collection_log_watermark", ct), + "V150 creates idx_collection_log_watermark"); + Assert.True(await IndexExistsAsync(connection, "idx_job_history_server_run", ct), + "V150 creates idx_job_history_server_run"); + } + + /// + /// Rerunning the migration ladder (as startup does on an already-migrated store) is idempotent: both + /// CREATE INDEX IF NOT EXISTS statements do not error and the indexes still exist. + /// + [Fact] + public async Task MigrateAsync_RunTwice_IsIdempotent() + { + var baseConnectionString = ConnectionString; + Assert.SkipWhen(string.IsNullOrEmpty(baseConnectionString), + "Set DARLING_TEST_PG to a Postgres connection string to run the V150 idempotency pin."); + + var ct = TestContext.Current.CancellationToken; + + await using var scratch = await ScratchPostgres.CreateAsync(baseConnectionString!, ct); + await using var connection = new NpgsqlConnection(scratch.ConnectionString); + await connection.OpenAsync(ct); + await PgMigrations.MigrateAsync(connection, ct); + await PgMigrations.MigrateAsync(connection, ct); + + Assert.True(await IndexExistsAsync(connection, "idx_collection_log_watermark", ct)); + Assert.True(await IndexExistsAsync(connection, "idx_job_history_server_run", ct)); + } + + private static async Task IndexExistsAsync(NpgsqlConnection connection, string indexName, System.Threading.CancellationToken ct) + { + await using var command = new NpgsqlCommand( + "SELECT EXISTS (SELECT 1 FROM pg_indexes WHERE schemaname = 'collect' AND indexname = $1)", connection); + command.Parameters.AddWithValue(indexName); + return (bool)(await command.ExecuteScalarAsync(ct))!; + } +} diff --git a/Darling/Darling.Tests/DarlingWatermarkFloorPlanShapeLiveTests.cs b/Darling/Darling.Tests/DarlingWatermarkFloorPlanShapeLiveTests.cs index 79abad9d1..c976557e0 100644 --- a/Darling/Darling.Tests/DarlingWatermarkFloorPlanShapeLiveTests.cs +++ b/Darling/Darling.Tests/DarlingWatermarkFloorPlanShapeLiveTests.cs @@ -98,12 +98,32 @@ the field shape (30 of 32 chunks compressed). The newest row per collector sits /* The plan-shape half: EXPLAIN the bounded statement and prove the chunk count is bounded by the floor, not the 30 days of retention just seeded. */ var floor = Whole(nowUtc) - DarlingWorker.WatermarkFloorLookback; - var boundedPlan = await ExplainAsync(connection, DarlingWorker.ReadCollectorWatermarksSql, floor, ct); + var collectorNames = new[] { "wait_stats", "index_object_stats" }; + var boundedPlan = await ExplainAsync(connection, DarlingWorker.ReadCollectorWatermarksSql, floor, ct, collectorNames); var boundedChunks = PlanChunkScans.DistinctChunkCount(boundedPlan); Assert.True(boundedChunks is >= 1 and <= 3, $"expected the 2-day floor to touch at most a couple of chunks (touched={boundedChunks}):\n{boundedPlan}"); + /* V150: one SubPlan/Limit descent per collector name, driven off unnest($3) — NOT a + GROUP BY/HashAggregate over every row in range. "Index Only Scan on idx_collection_log_watermark" + alone does not discriminate: the OLD GROUP BY statement, run against a store that already has + this index, ALSO plans as an Index Only Scan (TimescaleDB satisfies a GROUP BY's per-chunk + aggregate from the index instead of a table scan once the index exists) — Function Scan on + unnest only appears in the new per-collector LIMIT 1 shape, and HashAggregate/Finalize + HashAggregate only in the old GROUP BY shape. This is the exact regression this rung exists to + prevent: reverting the read to GROUP BY (variant C's shape too) must fail here even though the + index is still present. */ + Assert.Contains("idx_collection_log_watermark", boundedPlan, StringComparison.Ordinal); + Assert.Contains("Function Scan on unnest", boundedPlan, StringComparison.Ordinal); + Assert.True( + boundedPlan.Contains("Index Only Scan", StringComparison.Ordinal) + || boundedPlan.Contains("Index Scan", StringComparison.Ordinal), + $"expected an Index Only Scan or Index Scan on idx_collection_log_watermark:\n{boundedPlan}"); + Assert.DoesNotContain("Bitmap Heap Scan", boundedPlan, StringComparison.Ordinal); + Assert.DoesNotContain("Seq Scan on _hyper", boundedPlan, StringComparison.Ordinal); + Assert.DoesNotContain("HashAggregate", boundedPlan, StringComparison.Ordinal); + /* Seed a SECOND server at 2x the chunk count (60 days) and prove the bounded plan's chunk count does not grow with retention — the property #4469 exists to establish, not just "is small today". */ @@ -115,7 +135,7 @@ the field shape (30 of 32 chunks compressed). The newest row per collector sits } await CompressOldChunksAsync(connection, ct); - var widePlan = await ExplainAsync(connection, DarlingWorker.ReadCollectorWatermarksSql, floor, ct, wideServerId); + var widePlan = await ExplainAsync(connection, DarlingWorker.ReadCollectorWatermarksSql, floor, ct, collectorNames, wideServerId); var wideChunks = PlanChunkScans.DistinctChunkCount(widePlan); Assert.True(wideChunks <= boundedChunks + 1, $"expected doubling retained history to leave the bounded plan's chunk count essentially unchanged (30d={boundedChunks}, 60d={wideChunks}):\n{widePlan}"); @@ -188,11 +208,12 @@ failing the seed. */ } private static async Task ExplainAsync( - NpgsqlConnection connection, string sql, DateTime floor, CancellationToken ct, int? serverId = null) + NpgsqlConnection connection, string sql, DateTime floor, CancellationToken ct, string[] collectorNames, int? serverId = null) { using var command = new NpgsqlCommand("EXPLAIN (COSTS OFF) " + sql, connection); command.Parameters.AddWithValue(serverId ?? LiveServerId); command.Parameters.AddWithValue(floor); + command.Parameters.Add(new NpgsqlParameter { TypedValue = collectorNames }); await using var reader = await command.ExecuteReaderAsync(ct); var sb = new System.Text.StringBuilder(); while (await reader.ReadAsync(ct)) diff --git a/Darling/Darling.Tests/DarlingWatermarkIndexEquivalenceLiveTests.cs b/Darling/Darling.Tests/DarlingWatermarkIndexEquivalenceLiveTests.cs new file mode 100644 index 000000000..11f3e0246 --- /dev/null +++ b/Darling/Darling.Tests/DarlingWatermarkIndexEquivalenceLiveTests.cs @@ -0,0 +1,179 @@ +/* + * 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 System.Globalization; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Npgsql; +using PerformanceMonitor.Collectors; +using PerformanceMonitor.Darling.Service; +using PerformanceMonitor.Darling.Storage; +using Xunit; + +namespace Darling.Tests; + +/// +/// Store rung V150 (#4469, #4477): the per-collector index-only lookup +/// () must answer IDENTICALLY to the GROUP BY form it +/// replaces, floor and all — the win is plan shape (one index descent per collector instead of a scan of +/// every row in range), not a different answer. This seeds several collectors across several servers, with +/// old chunks compressed, and asserts (the +/// product's own call path) returns the same per-collector MAX(collection_time) the bounded GROUP BY form +/// would, including the "absent when never run within the floor" contract. +/// +[Collection("live-postgres")] +public sealed class DarlingWatermarkIndexEquivalenceLiveTests +{ + private const int LiveServerId = -150100; + private const string ServerName = "WM-EQUIV-V150-SRV"; + + [Fact] + public async Task ReadCollectorWatermarksAsync_MatchesTheBoundedGroupByOracle() + { + 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 watermark index equivalence test."); + + var ct = TestContext.Current.CancellationToken; + using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(ct); + await PgMigrations.MigrateAsync(connection, ct); + + var timescaleEnabled = await LiveTimescaleProbe.TryEnableAsync(connectionString!, ct); + if (timescaleEnabled) + { + Assert.True(await TimescaleSupport.EnsureCollectionLogHypertableAsync(connection, null, ct)); + } + + await DeleteLiveRowsAsync(connection, ct); + + var bodySucceeded = false; + try + { + var nowUtc = DateTime.UtcNow; + long logId = 4_150_100_000; + + /* Several real collector names (a mix of cadences), 10 days of history so most chunks + compress, so the equivalence holds across the compressed/uncompressed boundary too. */ + var collectors = new[] { "wait_stats", "index_object_stats", "query_stats", "memory_stats" }; + foreach (var collector in collectors) + { + for (var daysAgo = 9; daysAgo >= 0; daysAgo--) + { + var at = Whole(nowUtc.AddDays(-daysAgo)); + await InsertLogAsync(connection, ct, logId++, collector, at); + } + } + + /* One collector that ran, but only OUTSIDE the floor — the absent-when-never-run-within-floor + contract this rung must not regress (#4469). */ + await InsertLogAsync(connection, ct, logId++, "session_stats", Whole(nowUtc.AddDays(-9))); + + await CompressOldChunksAsync(connection, ct); + + var floor = DateTime.SpecifyKind(nowUtc - DarlingWorker.WatermarkFloorLookback, DateTimeKind.Unspecified); + + /* The oracle: the exact bounded GROUP BY form this rung replaces, run directly. */ + var expected = new Dictionary(StringComparer.OrdinalIgnoreCase); + using (var oracle = new NpgsqlCommand( + "SELECT collector_name, MAX(collection_time) FROM collection_log WHERE server_id = $1 AND collection_time >= $2 GROUP BY collector_name", + connection)) + { + oracle.Parameters.AddWithValue(LiveServerId); + oracle.Parameters.AddWithValue(floor); + await using var reader = await oracle.ExecuteReaderAsync(ct); + while (await reader.ReadAsync(ct)) + { + if (reader.IsDBNull(1)) + { + continue; + } + expected[reader.GetString(0)] = DateTime.SpecifyKind(reader.GetDateTime(1), DateTimeKind.Utc); + } + } + + Assert.True(expected.ContainsKey("wait_stats")); + Assert.False(expected.ContainsKey("session_stats"), "the outside-the-floor run must not appear in the oracle either"); + + /* THE PRODUCT'S OWN CALL PATH — no manual statement standing in for it. */ + await using var postgres = NpgsqlDataSource.Create(connectionString!); + var actual = await DarlingWorker.ReadCollectorWatermarksAsync(postgres, LiveServerId, logger: null, ct); + + foreach (var (name, ticks) in expected) + { + Assert.True(actual.ContainsKey(name), $"expected '{name}' in the actual read"); + Assert.Equal(ticks.Ticks, actual[name].Ticks); + } + + Assert.False(actual.ContainsKey("session_stats"), "outside-the-floor run must be absent from the real read too"); + + /* A collector CollectorScheduleDefaults knows about, but never seeded — absent, same as today. */ + Assert.False(actual.ContainsKey("cpu_utilization")); + + bodySucceeded = true; + } + finally + { + await LiveStoreCleanup.RunAsync(connectionString!, bodySucceeded, async (cleanup, cleanupCt) => + await DeleteLiveRowsAsync(cleanup, cleanupCt)); + } + } + + private static DateTime Whole(DateTime value) => + DateTime.SpecifyKind(new DateTime(value.Ticks / TimeSpan.TicksPerSecond * TimeSpan.TicksPerSecond), DateTimeKind.Unspecified); + + private static async Task InsertLogAsync( + NpgsqlConnection connection, CancellationToken ct, long logId, string collector, DateTime time) + { + using var command = new NpgsqlCommand(@" +INSERT INTO collection_log (log_id, server_id, server_name, collector_name, collection_time, duration_ms, status, error_message, rows_collected, sql_duration_ms, duckdb_duration_ms) +VALUES ($1, $2, $3, $4, $5, 0, 'SUCCESS', NULL, 0, 0, 0)", connection); + command.Parameters.AddWithValue(logId); + command.Parameters.AddWithValue(LiveServerId); + command.Parameters.AddWithValue(ServerName); + command.Parameters.AddWithValue(collector); + command.Parameters.AddWithValue(time); + await command.ExecuteNonQueryAsync(ct); + } + + private static async Task CompressOldChunksAsync(NpgsqlConnection connection, CancellationToken ct) + { + using var chunkList = new NpgsqlCommand( + "SELECT show_chunks('collection_log', older_than => INTERVAL '1 day')::text", connection); + await using var reader = await chunkList.ExecuteReaderAsync(ct); + var chunks = new List(); + while (await reader.ReadAsync(ct)) + { + chunks.Add(reader.GetString(0)); + } + await reader.DisposeAsync(); + + foreach (var chunk in chunks) + { + try + { + using var compress = new NpgsqlCommand($"SELECT compress_chunk('{chunk}', if_not_compressed => true)", connection); + await compress.ExecuteNonQueryAsync(ct); + } + catch (PostgresException) + { + /* Already compressed, or not eligible yet — fine either way for this seed's purpose. */ + } + } + } + + private static async Task DeleteLiveRowsAsync(NpgsqlConnection connection, CancellationToken ct) + { + var sid = LiveServerId.ToString(CultureInfo.InvariantCulture); + using var cleanup = new NpgsqlCommand($"DELETE FROM collection_log WHERE server_id = {sid};", connection); + await cleanup.ExecuteNonQueryAsync(ct); + } +} diff --git a/Darling/Darling.Tests/MigrationDataMovingRungCensusPins.cs b/Darling/Darling.Tests/MigrationDataMovingRungCensusPins.cs index 9dbb70d29..e3d894d7a 100644 --- a/Darling/Darling.Tests/MigrationDataMovingRungCensusPins.cs +++ b/Darling/Darling.Tests/MigrationDataMovingRungCensusPins.cs @@ -171,6 +171,20 @@ public sealed class MigrationDataMovingRungCensusPins + "V62's and V137's shape on the same control-plane singleton (id = 1, CHECK (id = 1)), so the " + "UPDATE's WHERE compose_statement_timeout_seconds = 15 touches at most one row and spends none " + "of the budget"), + new( + 150, + SetsTheFloor: false, + "two CREATE INDEXes over populated hypertables (#4469, #4477): idx_collection_log_watermark on " + + "collect.collection_log (created V2) and idx_job_history_server_run on collect.job_history " + + "(created V1/V24). V104's and V142's case rather than V22's/V23's/V39's: real collected series, " + + "but costed rather than assumed. Measured on a rig shaped like the field (43 servers, ~40 " + + "collectors, 15M collection_log rows, 9 of 11 chunks compressed): the collection_log index built " + + "in ~0.94 s. A separate rig for job_history (43 servers, ~2.7M rows over 4 days, 3 of 5 chunks " + + "compressed) built its index in well under a second too. CompressAfterDays leaves only the " + + "newest day or two of either table uncompressed at migration time, with every older chunk's " + + "decompressed relation an empty shell (one 8 KB page per compressed chunk, V142's own measured " + + "property) - so a store many times today's size would still build both in low seconds, nowhere " + + "near a MigrationCommandTimeoutSeconds window, and this rung does not move the multiple"), ]; /// diff --git a/Darling/Darling.Tests/QueryStoreLivenessHotTouchLiveTests.cs b/Darling/Darling.Tests/QueryStoreLivenessHotTouchLiveTests.cs index 5097a50b3..f7218bc83 100644 --- a/Darling/Darling.Tests/QueryStoreLivenessHotTouchLiveTests.cs +++ b/Darling/Darling.Tests/QueryStoreLivenessHotTouchLiveTests.cs @@ -24,8 +24,9 @@ namespace Darling.Tests; /// reloption, startup's CREATE TABLE IF NOT EXISTS convergence does not recreate the index, and two /// touches of the same row report a HOT update via pg_stat_user_tables.n_tup_hot_upd. /// -/// This file's "I am the top rung" claim takes over from ReadLatencyFlushLiveTests (V148) now -/// that V149 has landed. +/// This file's "I am the top rung" claim moved to CollectionLogWatermarkAndJobHistoryIndexesRungTests +/// (V150) now that V150 has landed; this file's own rung/probe facts below keep asserting what stays true +/// forever (present, in-order, gated behind the arm above it) rather than "is exactly the top". /// /* #1776 own-store: each fact mints its own scratch database through ScratchPostgres and never touches the shared store's tables, so it cannot race the live collection and serializing it would be pure slowdown. */ @@ -40,19 +41,20 @@ public sealed class QueryStoreLivenessHotTouchLiveTests private static string? ConnectionString => Environment.GetEnvironmentVariable("DARLING_TEST_PG"); /// - /// The rung is registered and is the new top of the ladder — the claim this class takes over from - /// ReadLatencyFlushLiveTests (V148) now that V149 has landed. + /// The rung is registered, and the ladder stays dense above the historical gap — the claim this class + /// took over from ReadLatencyFlushLiveTests (V148) moved on again to + /// CollectionLogWatermarkAndJobHistoryIndexesRungTests (V150) now that V150 has landed. /// [Fact] - public void TheRungIsRegisteredAtTheTopOfADenseLadder() + public void TheRungIsRegistered_AndTheLadderIsDenseAboveTheHistoricalGap() { var versions = PgMigrations.Scripts.Select(s => s.Version).ToList(); Assert.Equal("query-store-liveness-hot-touch", PgMigrations.Scripts.Single(s => s.Version == RungVersion).Name); - Assert.Equal(StorageVersion.SchemaVersion, PgMigrations.Scripts[^1].Version); - Assert.Equal(StorageVersion.SchemaVersion, versions.Max()); - Assert.Equal(RungVersion, StorageVersion.SchemaVersion); Assert.Equal(versions.Distinct().OrderBy(v => v), versions); + + var above = versions.Where(v => v > 45).OrderBy(v => v).ToList(); + Assert.Equal(Enumerable.Range(above[0], above.Count), above); } /// @@ -61,7 +63,7 @@ public void TheRungIsRegisteredAtTheTopOfADenseLadder() /// is . /// [Fact] - public void TheProbeMapsAFullyMigratedStoreToThisTopRung() + public void TheProbeCarriesThisRungsSentinel_AndTheArmSitsBelowTheCurrentTop() { var probe = ViewerDataService.StoreSchemaProbeSql.Replace("\r\n", "\n", StringComparison.Ordinal); Assert.Contains("idx_query_store_plan_map_last_seen", probe, StringComparison.Ordinal); @@ -69,29 +71,30 @@ public void TheProbeMapsAFullyMigratedStoreToThisTopRung() var viewer = RepoFile.ReadRepoFile("Darling", "PerformanceMonitor.Darling.Viewer", "ViewerDataService.cs"); Assert.Contains($"reader.GetBoolean({ProbeOrdinal})", viewer, StringComparison.Ordinal); - Assert.DoesNotContain($"reader.GetBoolean({ProbeOrdinal + 1})", viewer, StringComparison.Ordinal); - - Assert.Equal(StorageVersion.SchemaVersion, ViewerDataService.RequiredStoreSchemaVersion); var method = typeof(ViewerDataService).GetMethod("MapProbedSchemaVersion", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static)!; var arity = method.GetParameters().Length; - Assert.Equal(ProbeOrdinal, arity - 1); Assert.Equal("hasHotLivenessTouch", method.GetParameters()[ProbeOrdinal].Name); + /* Every rung above this one (V150's hasCollectionLogWatermarkAndJobHistoryIndexes) must also be + false, or the map finds the newer arm first and this assertion is checking the wrong rung's + fallthrough. */ var all = Enumerable.Repeat((object)true, arity).ToArray(); - Assert.Equal(StorageVersion.SchemaVersion, (int)method.Invoke(null, all)!); - var behind = (object[])all.Clone(); behind[ProbeOrdinal] = false; + behind[arity - 1] = false; Assert.Equal(PreviousVersion, (int)method.Invoke(null, behind)!); + /* V150 (#4469, #4477) is now the top rung, so this arm no longer needs to be the LAST one — it only + has to sit below the current top's arm, which is what the ladder-dense invariant above already + guarantees is registered ahead of it. */ var thisArm = viewer.IndexOf("if (hasHotLivenessTouch)", StringComparison.Ordinal); - var previousArm = viewer.IndexOf("if (hasReadLatency)", StringComparison.Ordinal); + var topArm = viewer.IndexOf("if (hasCollectionLogWatermarkAndJobHistoryIndexes)", StringComparison.Ordinal); Assert.True(thisArm >= 0, "the viewer has no V149 sentinel arm — a fully-migrated store would map one rung short"); - Assert.True(thisArm < previousArm, "the V149 arm sits below V148's, so a current store maps one rung short"); + Assert.True(topArm >= 0 && topArm < thisArm, "the current top rung's arm must sit above the V149 arm"); Assert.Contains( - "return " + StorageVersion.SchemaVersion.ToString(System.Globalization.CultureInfo.InvariantCulture) + ";", - viewer[thisArm..previousArm], StringComparison.Ordinal); + "return " + RungVersion.ToString(System.Globalization.CultureInfo.InvariantCulture) + ";", + viewer[thisArm..(viewer.IndexOf("if (hasReadLatency)", StringComparison.Ordinal))], StringComparison.Ordinal); } /// diff --git a/Darling/Darling.Tests/ReadLatencyFlushLiveTests.cs b/Darling/Darling.Tests/ReadLatencyFlushLiveTests.cs index f242c59b0..865b8dbe9 100644 --- a/Darling/Darling.Tests/ReadLatencyFlushLiveTests.cs +++ b/Darling/Darling.Tests/ReadLatencyFlushLiveTests.cs @@ -76,19 +76,22 @@ public void TheProbeCarriesThisRungsSentinel_AndTheArmSitsBelowTheCurrentTop() var arity = method.GetParameters().Length; Assert.Equal("hasReadLatency", method.GetParameters()[ProbeOrdinal].Name); - /* Every rung above this one (V149's hasHotLivenessTouch) must also be false, or the map finds the - newer arm first and this assertion is checking the wrong rung's fallthrough. */ + /* Every rung above this one (V149's hasHotLivenessTouch, V150's + hasCollectionLogWatermarkAndJobHistoryIndexes) must also be false, or the map finds a newer arm + first and this assertion is checking the wrong rung's fallthrough. */ var all = Enumerable.Repeat((object)true, arity).ToArray(); var behind = (object[])all.Clone(); - behind[ProbeOrdinal] = false; - behind[arity - 1] = false; + for (var i = ProbeOrdinal; i < arity; i++) + { + behind[i] = false; + } Assert.Equal(PreviousVersion, (int)method.Invoke(null, behind)!); - /* V149 (#4250) is now the top rung, so this arm no longer needs to be the LAST one — it only has - to sit below the current top's arm, which is what the ladder-dense invariant above already + /* V150 (#4469, #4477) is now the top rung, so this arm no longer needs to be the LAST one — it only + has to sit below the current top's arm, which is what the ladder-dense invariant above already guarantees is registered ahead of it. */ var thisArm = viewer.IndexOf("if (hasReadLatency)", StringComparison.Ordinal); - var topArm = viewer.IndexOf("if (hasHotLivenessTouch)", StringComparison.Ordinal); + var topArm = viewer.IndexOf("if (hasCollectionLogWatermarkAndJobHistoryIndexes)", StringComparison.Ordinal); Assert.True(thisArm >= 0, "the viewer has no V148 sentinel arm \u2014 a fully-migrated store would map one rung short"); Assert.True(topArm >= 0 && topArm < thisArm, "the current top rung's arm must sit above the V148 arm"); Assert.Contains( diff --git a/Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs b/Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs index df84af196..ad1250d75 100644 --- a/Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs +++ b/Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs @@ -4692,18 +4692,39 @@ internal static DateTime ComputeSeededNextDue(DateTime? lastRunUtc, int frequenc /// see the method's own doc comment for why this is safe. internal static readonly TimeSpan WatermarkFloorLookback = TimeSpan.FromDays(2); - /// The bounded statement itself (#4469), pinned by name so a live/plan test can assert its - /// text and shape directly rather than re-deriving it from the call site. $1 server_id; $2 the floor - /// (DateTime.UtcNow - WatermarkFloorLookback, bound naive-UTC (Unspecified) to match the naive - /// timestamp column, so Npgsql sends timestamp and TimescaleDB excludes the old chunks - /// with no cast on collection_time — the same product-wide contract as - /// ). + /// The bounded statement itself (#4469/#4477 store rung V150), pinned by name so a live/plan + /// test can assert its text and shape directly rather than re-deriving it from the call site. $1 + /// server_id; $2 the floor (DateTime.UtcNow - WatermarkFloorLookback, bound naive-UTC + /// (Unspecified) to match the naive timestamp column, so Npgsql sends timestamp and + /// TimescaleDB excludes the old chunks with no cast on collection_time — the same product-wide + /// contract as ); $3 the requested collector names, bound + /// as a text[]. + /// + /// One index-only descent per collector, not a GROUP BY over every matching row (V150). + /// The prior text was SELECT collector_name, MAX(collection_time) FROM collection_log WHERE + /// server_id = $1 AND collection_time >= $2 GROUP BY collector_name, which still had to read every + /// row in the newest chunks for this server (a Bitmap Heap Scan, the 89% of the pre-floor cold cost the + /// #4469 floor above did not touch) before it could answer, because TimescaleDB has no index shaped to + /// answer "the newest row per collector" directly. idx_collection_log_watermark (server_id, + /// collector_name, collection_time DESC) gives it one: for each name in $3, an ORDER BY + /// collection_time DESC LIMIT 1 sub-select is an index-only descent, not a scan. Measured on a rig + /// shaped like the field (43 servers, ~40 collectors, 15M rows, 9 of 11 chunks compressed): the new + /// shape runs ~0.65 ms against the old statement's ~24 ms, cold and warm alike — a >30x floor + /// reduction, because the read goes from "scan every row in the newest chunks for this server" to "one + /// index descent per collector name." $3 costs nothing extra to produce: both call sites already hold + /// CollectorScheduleDefaults.All.Keys in the very next line. + /// internal const string ReadCollectorWatermarksSql = """ - SELECT collector_name, MAX(collection_time) - FROM collection_log - WHERE server_id = $1 - AND collection_time >= $2 - GROUP BY collector_name + SELECT c.name, ( + SELECT l.collection_time + FROM collection_log l + WHERE l.server_id = $1 + AND l.collector_name = c.name + AND l.collection_time >= $2 + ORDER BY l.collection_time DESC + LIMIT 1 + ) + FROM unnest($3::text[]) AS c(name) """; /// @@ -4759,6 +4780,10 @@ internal static async Task> ReadCollectorWatermarks { TypedValue = DateTime.SpecifyKind(DateTime.UtcNow - WatermarkFloorLookback, DateTimeKind.Unspecified), }); + command.Parameters.Add(new NpgsqlParameter + { + TypedValue = CollectorScheduleDefaults.All.Keys.ToArray(), + }); await using var reader = await command.ExecuteReaderAsync(cancellationToken); while (await reader.ReadAsync(cancellationToken)) diff --git a/Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs b/Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs index cc3f9a328..15af21940 100644 --- a/Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs +++ b/Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs @@ -229,6 +229,7 @@ holds the ordering. */ new Migration(147, "compose-statement-timeout-sixty", V147Sql), new Migration(148, "read-latency", V148Sql), new Migration(149, "query-store-liveness-hot-touch", V149Sql), + new Migration(150, "collection-log-watermark-and-job-history-indexes", V150Sql), }; /// @@ -2358,6 +2359,50 @@ CREATE INDEX IF NOT EXISTS idx_read_latency_time ALTER TABLE collect.query_store_plan_map SET (fillfactor = 90); ALTER TABLE collect.query_store_text SET (fillfactor = 90);"; + /// + /// V150 — two indexes, added additively for both #4469 and #4477: + /// + /// idx_collection_log_watermark on collect.collection_log (server_id, collector_name, + /// collection_time DESC) so + /// can look up each collector's newest run with one index-only descent per collector instead of a + /// bitmap heap scan of the newest chunks. Measured on a rig shaped like the field (43 servers, ~40 + /// collectors, 15M rows, 9 of 11 chunks compressed): the per-collector lookup runs ~0.65 ms against + /// the old statement's ~24 ms, cold and warm alike, because it turns the read from a scan of every + /// row in the newest chunks into one index-only descent per collector name. Costs: ~0.94 s to build + /// on 2.4M uncompressed rows (well inside MigrationCommandTimeoutSeconds), ~115 MB, and +38% + /// wall time on a 100,000-row bulk COPY into the newest chunk (0.353 s -> 0.487 s median of 3) — + /// one more btree every future collection_log write maintains. + /// idx_job_history_server_run on collect.job_history (server_id, run_datetime DESC, + /// instance_id DESC) for the Viewer's Job History tab + /// (), matching that + /// read's own ORDER BY run_datetime_utc DESC, instance_id DESC tie-break so a per-server + /// top-N lookup needs no additional sort on the indexed columns. Measured on a rig shaped like the + /// field (43 servers, ~28,000 rows/server over 4 days, 3 of 5 chunks compressed): the base row + /// selection this index serves reads ~1,506 buffers cold against the unindexed scan's ~6,883 (about + /// 4.6x fewer), and the full Job History read (including the per-job stats aggregate this index does + /// not cover) runs ~721 ms cold against ~1,362 ms (about 1.9x) — the win is smaller than the + /// watermark index's because the read's other half, job_stats, still scans every matching row + /// in the window to compute an average/max per job and this index does not help that half. + /// + /// Both are plain CREATE INDEX IF NOT EXISTS (mirroring V149's shape): MigrateAsync wraps + /// every rung's whole SQL in one transaction, and CREATE INDEX ... WITH + /// (timescaledb.transaction_per_chunk) cannot run inside one — measured, it raises + /// CREATE INDEX ... WITH (timescaledb.transaction_per_chunk) cannot run inside a transaction + /// block. Each build takes a ShareLock for its duration (confirmed via pg_locks), + /// blocking concurrent inserts/updates/deletes to that table until the build finishes — acceptable at + /// the measured field-store extrapolation of well under 2 seconds each, but a store whose uncompressed + /// chunks have grown unusually large (a long compression-policy gap, or a raised + /// CompressAfterDays) would make this rung's lock window grow linearly with the uncompressed + /// row count. IF NOT EXISTS makes both idempotent on a FRESH store too: PgSchemaGenerator + /// does not build either index on a fresh install today, so this rung is the real create on both + /// paths, with no fresh-vs-upgraded shape divergence to special-case. + /// + private const string V150Sql = @" +CREATE INDEX IF NOT EXISTS idx_collection_log_watermark + ON collect.collection_log (server_id, collector_name, collection_time DESC); +CREATE INDEX IF NOT EXISTS idx_job_history_server_run + ON collect.job_history (server_id, run_datetime DESC, instance_id DESC);"; + /// /// V2 — the service's observability store: the servers registry (upserted on every /// successful connect) and the per-run collection_log. Column names deliberately mirror diff --git a/Darling/PerformanceMonitor.Darling.Storage/StorageVersion.cs b/Darling/PerformanceMonitor.Darling.Storage/StorageVersion.cs index 0059394e9..6dad0263d 100644 --- a/Darling/PerformanceMonitor.Darling.Storage/StorageVersion.cs +++ b/Darling/PerformanceMonitor.Darling.Storage/StorageVersion.cs @@ -16,5 +16,5 @@ namespace PerformanceMonitor.Darling.Storage; /// public static class StorageVersion { - public const int SchemaVersion = 149; + public const int SchemaVersion = 150; } diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs index 99ee907b3..8b529d7d7 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs @@ -978,7 +978,13 @@ liveness touch's UPDATE can go HOT. This probes NEGATIVELY — the index's ABSEN yet read by any viewer surface, so this gate rests on the standing invariant alone. Named only in this probe line, never in prose, per the V71 finding. */ ((SELECT c.reloptions FROM pg_class c WHERE c.oid = 'collect.query_store_plan_map'::regclass) @> ARRAY['fillfactor=90'] - AND NOT EXISTS (SELECT 1 FROM pg_indexes WHERE schemaname = 'collect' AND indexname = 'idx_query_store_plan_map_last_seen'))"; + AND NOT EXISTS (SELECT 1 FROM pg_indexes WHERE schemaname = 'collect' AND indexname = 'idx_query_store_plan_map_last_seen')), + /* V150 (#4469, #4477) adds two supporting indexes: idx_collection_log_watermark (the per-collector + watermark lookup) and idx_job_history_server_run (the Viewer's Job History tab read). It is not yet + read by any viewer surface, so this gate rests on the standing invariant alone. Named only in this + probe line, never in prose, per the V71 finding. */ + (EXISTS (SELECT 1 FROM pg_indexes WHERE schemaname = 'collect' AND indexname = 'idx_collection_log_watermark') + AND EXISTS (SELECT 1 FROM pg_indexes WHERE schemaname = 'collect' AND indexname = 'idx_job_history_server_run'))"; /// The store schema version this viewer build requires — the highest migration it knows /// (). The connect-time gate blocks a store below this. @@ -1000,7 +1006,7 @@ liveness touch's UPDATE can go HOT. This probes NEGATIVELY — the index's ABSEN await using var reader = await command.ExecuteReaderAsync(cancellationToken); if (await reader.ReadAsync(cancellationToken)) { - return MapProbedSchemaVersion(reader.GetBoolean(0), reader.GetBoolean(1), reader.GetBoolean(2), reader.GetBoolean(3), reader.GetBoolean(4), reader.GetBoolean(5), reader.GetBoolean(6), reader.GetBoolean(7), reader.GetBoolean(8), reader.GetBoolean(9), reader.GetBoolean(10), reader.GetBoolean(11), reader.GetBoolean(12), reader.GetBoolean(13), reader.GetBoolean(14), reader.GetBoolean(15), reader.GetBoolean(16), reader.GetBoolean(17), reader.GetBoolean(18), reader.GetBoolean(19), reader.GetBoolean(20), reader.GetBoolean(21), reader.GetBoolean(22), reader.GetBoolean(23), reader.GetBoolean(24), reader.GetBoolean(25), reader.GetBoolean(26), reader.GetBoolean(27), reader.GetBoolean(28), reader.GetBoolean(29), reader.GetBoolean(30), reader.GetBoolean(31), reader.GetBoolean(32), reader.GetBoolean(33), reader.GetBoolean(34), reader.GetBoolean(35), reader.GetBoolean(36), reader.GetBoolean(37), reader.GetBoolean(38), reader.GetBoolean(39), reader.GetBoolean(40), reader.GetBoolean(41), reader.GetBoolean(42), reader.GetBoolean(43), reader.GetBoolean(44), reader.GetBoolean(45), reader.GetBoolean(46), reader.GetBoolean(47), reader.GetBoolean(48), reader.GetBoolean(49), reader.GetBoolean(50), reader.GetBoolean(51), reader.GetBoolean(52), reader.GetBoolean(53), reader.GetBoolean(54), reader.GetBoolean(55), reader.GetBoolean(56), reader.GetBoolean(57), reader.GetBoolean(58), reader.GetBoolean(59), reader.GetBoolean(60), reader.GetBoolean(61), reader.GetBoolean(62), reader.GetBoolean(63), reader.GetBoolean(64), reader.GetBoolean(65), reader.GetBoolean(66), reader.GetBoolean(67), reader.GetBoolean(68), reader.GetBoolean(69), reader.GetBoolean(70), reader.GetBoolean(71), reader.GetBoolean(72), reader.GetBoolean(73), reader.GetBoolean(74), reader.GetBoolean(75), reader.GetBoolean(76), reader.GetBoolean(77), reader.GetBoolean(78), reader.GetBoolean(79), reader.GetBoolean(80), reader.GetBoolean(81), reader.GetBoolean(82), reader.GetBoolean(83), reader.GetBoolean(84), reader.GetBoolean(85), reader.GetBoolean(86), reader.GetBoolean(87), reader.GetBoolean(88), reader.GetBoolean(89), reader.GetBoolean(90), reader.GetBoolean(91), reader.GetBoolean(92), reader.GetBoolean(93), reader.GetBoolean(94), reader.GetBoolean(95), reader.GetBoolean(96), reader.GetBoolean(97), reader.GetBoolean(98), reader.GetBoolean(99), reader.GetBoolean(100), reader.GetBoolean(101), reader.GetBoolean(102), reader.GetBoolean(103), reader.GetBoolean(104), reader.GetBoolean(105), reader.GetBoolean(106), reader.GetBoolean(107), reader.GetBoolean(108), reader.GetBoolean(109), reader.GetBoolean(110), reader.GetBoolean(111), reader.GetBoolean(112), reader.GetBoolean(113), reader.GetBoolean(114), reader.GetBoolean(115), reader.GetBoolean(116), reader.GetBoolean(117), reader.GetBoolean(118), reader.GetBoolean(119), reader.GetBoolean(120), reader.GetBoolean(121), reader.GetBoolean(122), reader.GetBoolean(123), reader.GetBoolean(124)); + return MapProbedSchemaVersion(reader.GetBoolean(0), reader.GetBoolean(1), reader.GetBoolean(2), reader.GetBoolean(3), reader.GetBoolean(4), reader.GetBoolean(5), reader.GetBoolean(6), reader.GetBoolean(7), reader.GetBoolean(8), reader.GetBoolean(9), reader.GetBoolean(10), reader.GetBoolean(11), reader.GetBoolean(12), reader.GetBoolean(13), reader.GetBoolean(14), reader.GetBoolean(15), reader.GetBoolean(16), reader.GetBoolean(17), reader.GetBoolean(18), reader.GetBoolean(19), reader.GetBoolean(20), reader.GetBoolean(21), reader.GetBoolean(22), reader.GetBoolean(23), reader.GetBoolean(24), reader.GetBoolean(25), reader.GetBoolean(26), reader.GetBoolean(27), reader.GetBoolean(28), reader.GetBoolean(29), reader.GetBoolean(30), reader.GetBoolean(31), reader.GetBoolean(32), reader.GetBoolean(33), reader.GetBoolean(34), reader.GetBoolean(35), reader.GetBoolean(36), reader.GetBoolean(37), reader.GetBoolean(38), reader.GetBoolean(39), reader.GetBoolean(40), reader.GetBoolean(41), reader.GetBoolean(42), reader.GetBoolean(43), reader.GetBoolean(44), reader.GetBoolean(45), reader.GetBoolean(46), reader.GetBoolean(47), reader.GetBoolean(48), reader.GetBoolean(49), reader.GetBoolean(50), reader.GetBoolean(51), reader.GetBoolean(52), reader.GetBoolean(53), reader.GetBoolean(54), reader.GetBoolean(55), reader.GetBoolean(56), reader.GetBoolean(57), reader.GetBoolean(58), reader.GetBoolean(59), reader.GetBoolean(60), reader.GetBoolean(61), reader.GetBoolean(62), reader.GetBoolean(63), reader.GetBoolean(64), reader.GetBoolean(65), reader.GetBoolean(66), reader.GetBoolean(67), reader.GetBoolean(68), reader.GetBoolean(69), reader.GetBoolean(70), reader.GetBoolean(71), reader.GetBoolean(72), reader.GetBoolean(73), reader.GetBoolean(74), reader.GetBoolean(75), reader.GetBoolean(76), reader.GetBoolean(77), reader.GetBoolean(78), reader.GetBoolean(79), reader.GetBoolean(80), reader.GetBoolean(81), reader.GetBoolean(82), reader.GetBoolean(83), reader.GetBoolean(84), reader.GetBoolean(85), reader.GetBoolean(86), reader.GetBoolean(87), reader.GetBoolean(88), reader.GetBoolean(89), reader.GetBoolean(90), reader.GetBoolean(91), reader.GetBoolean(92), reader.GetBoolean(93), reader.GetBoolean(94), reader.GetBoolean(95), reader.GetBoolean(96), reader.GetBoolean(97), reader.GetBoolean(98), reader.GetBoolean(99), reader.GetBoolean(100), reader.GetBoolean(101), reader.GetBoolean(102), reader.GetBoolean(103), reader.GetBoolean(104), reader.GetBoolean(105), reader.GetBoolean(106), reader.GetBoolean(107), reader.GetBoolean(108), reader.GetBoolean(109), reader.GetBoolean(110), reader.GetBoolean(111), reader.GetBoolean(112), reader.GetBoolean(113), reader.GetBoolean(114), reader.GetBoolean(115), reader.GetBoolean(116), reader.GetBoolean(117), reader.GetBoolean(118), reader.GetBoolean(119), reader.GetBoolean(120), reader.GetBoolean(121), reader.GetBoolean(122), reader.GetBoolean(123), reader.GetBoolean(124), reader.GetBoolean(125)); } return null; @@ -1025,7 +1031,7 @@ liveness touch's UPDATE can go HOT. This probes NEGATIVELY — the index's ABSEN /// is unit-tested without a live store; any schema bump past the newest arm trips the pinning test that keeps /// this in step with . /// - internal static int MapProbedSchemaVersion(bool hasConfigControlPlane, bool hasAlertDeliveryOverride, bool hasAnalysisState, bool hasAlertTuningKnobs, bool hasDefaultTraceEvents, bool hasIndexObjectStatsLatestIndex, bool hasCollectionLogHypertableOrPlainPg, bool hasJobHistory, bool hasAgentStatus, bool hasGenericWebhook, bool hasDeadlocksDatabaseName, bool hasQueryStoreReplicaRole, bool hasLongQueryCompletions, bool hasWebDashboardConfig, bool hasCustomViews, bool hasServerTags, bool hasConnectionRefireKnobs = false, bool hasAgCollectors = false, bool hasAgAlertKnobs = false, bool hasAgLatencyColumns = false, bool hasAgDisconnectRefire = false, bool hasPayloadDimensions = false, bool hasDimFloorIndexes = false, bool hasBlockingWaitThreshold = false, bool hasQueryStoreIntervalIdentity = false, bool hasPagerDutyWebhook = false, bool hasPagerDutyProxy = false, bool hasCollectorState = false, bool hasPlanCorrection = false, bool hasPvsStats = false, bool hasPvsPressureKnobs = false, bool hasDatabaseStateAlert = false, bool hasServerTagColour = false, bool hasQueryStatsHostObject = false, bool hasFindingDrillDown = false, bool hasStoreMetrics = false, bool hasPlanDimGzip = false, bool hasSelfAlertKnobs = false, bool hasJobMetricsColumns = false, bool hasJobCadenceKnob = false, bool hasBackfillSwitch = false, bool hasCollectorMemoryKnobs = false, bool hasDatabaseStateEdgeMemory = false, bool hasIncidentOccurrences = false, bool hasPlanXmlCompressionKnob = false, bool hasMonitoredServerEngine = false, bool hasPgBlockingEdges = false, bool hasQueryStorePlanMap = false, bool hasPgStatementText = false, bool hasQueryStoreText = false, bool hasPlanContentRetentionKnob = false, bool hasQueryStoreHealth = false, bool hasQueryStoreTextHash = false, bool hasComposeTimeoutKnob = false, bool hasFileGrowthAlert = false, bool hasCollectionLogFanoutRollup = false, bool hasTempDbMaxSize = false, bool hasServerEngineKind = false, bool hasPgDatabaseStats = false, bool hasPgIndexUsageStats = false, bool hasPgTableBloatStats = false, bool hasPgSessionStates = false, bool hasPgPlanCaptureReadiness = false, bool hasPgWriteStats = false, bool hasPgExtensionAvailability = false, bool hasPgLockStats = false, bool hasPgColumnStats = false, bool hasPgReplicationStats = false, bool hasPgBufferUsage = false, bool hasPgIndexBloat = false, bool hasPgPerDatabaseAttribution = false, bool hasPgWaitSampling = false, bool hasPgKernelStats = false, bool hasPgPredicateStats = false, bool hasPgPlanCapture = false, bool hasPgMajorVersion = false, bool hasPg18IoBytes = false, bool hasPgServerConfig = false, bool hasPgDeadlocks = false, bool hasPgDeadlockIdentity = false, bool hasCollectorCost = false, bool hasPgCpuUtilization = false, bool hasPlanForceActions = false, bool hasCollectionLogPhaseSplit = false, bool hasCollectionLogDrainForensics = false, bool hasCollectionLogFetchPhaseSums = false, bool hasStoreLogSelfMonitoring = false, bool hasCollectorStallProbes = false, bool hasRemediationCredentialAndActor = false, bool hasPgIndexBloatEstimate = false, bool hasPgCpuCapacityHeadroom = false, bool hasCustomAlertCore = false, bool hasMuteRuleReloadBeacon = false, bool hasBuiltinAlertPersistence = false, bool hasRetentionHoldRatioKnobs = false, bool hasDeadlockRateBandKnobs = false, bool hasOversizedPlanBacklog = false, bool hasPgAlertCountKnobs = false, bool hasFleetSweepState = false, bool hasFleetSweepCadenceKnobs = false, bool hasCollectorScheduleDatabases = false, bool hasSelfDiskWarnGbFloor = false, bool hasDeltaFamilyIntervalColumns = false, bool hasDeltaFamilyIntervalCompletion = false, bool hasPgLogEvents = false, bool hasPgLogEventMetrics = false, bool hasNotificationRoutes = false, bool hasPerfmonCounterType = false, bool hasPgNumbackendsAndSampledMs = false, bool hasTimeHonesty = false, bool hasLrqExclusionKnob = false, bool hasPgDatabaseSizeStatsAndHostMemory = false, bool hasQsCaptureModeRouteKnobToast = false, bool hasPgServerConfigDatabaseRoleOverrides = false, bool hasPostmasterStartTime = false, bool hasCheckpointsTimed = false, bool hasCollectionCaveats = false, bool hasIndexObjectStatsServerTimeIndex = false, bool hasQueryStoreIntervalLatest = false, bool hasRawChunkIntervalRungHistory = false, bool hasQueryStoreIntervalWide = false, bool hasManagedConfVerdicts = false, bool hasComposeTimeoutSixty = false, bool hasReadLatency = false, bool hasHotLivenessTouch = false) + internal static int MapProbedSchemaVersion(bool hasConfigControlPlane, bool hasAlertDeliveryOverride, bool hasAnalysisState, bool hasAlertTuningKnobs, bool hasDefaultTraceEvents, bool hasIndexObjectStatsLatestIndex, bool hasCollectionLogHypertableOrPlainPg, bool hasJobHistory, bool hasAgentStatus, bool hasGenericWebhook, bool hasDeadlocksDatabaseName, bool hasQueryStoreReplicaRole, bool hasLongQueryCompletions, bool hasWebDashboardConfig, bool hasCustomViews, bool hasServerTags, bool hasConnectionRefireKnobs = false, bool hasAgCollectors = false, bool hasAgAlertKnobs = false, bool hasAgLatencyColumns = false, bool hasAgDisconnectRefire = false, bool hasPayloadDimensions = false, bool hasDimFloorIndexes = false, bool hasBlockingWaitThreshold = false, bool hasQueryStoreIntervalIdentity = false, bool hasPagerDutyWebhook = false, bool hasPagerDutyProxy = false, bool hasCollectorState = false, bool hasPlanCorrection = false, bool hasPvsStats = false, bool hasPvsPressureKnobs = false, bool hasDatabaseStateAlert = false, bool hasServerTagColour = false, bool hasQueryStatsHostObject = false, bool hasFindingDrillDown = false, bool hasStoreMetrics = false, bool hasPlanDimGzip = false, bool hasSelfAlertKnobs = false, bool hasJobMetricsColumns = false, bool hasJobCadenceKnob = false, bool hasBackfillSwitch = false, bool hasCollectorMemoryKnobs = false, bool hasDatabaseStateEdgeMemory = false, bool hasIncidentOccurrences = false, bool hasPlanXmlCompressionKnob = false, bool hasMonitoredServerEngine = false, bool hasPgBlockingEdges = false, bool hasQueryStorePlanMap = false, bool hasPgStatementText = false, bool hasQueryStoreText = false, bool hasPlanContentRetentionKnob = false, bool hasQueryStoreHealth = false, bool hasQueryStoreTextHash = false, bool hasComposeTimeoutKnob = false, bool hasFileGrowthAlert = false, bool hasCollectionLogFanoutRollup = false, bool hasTempDbMaxSize = false, bool hasServerEngineKind = false, bool hasPgDatabaseStats = false, bool hasPgIndexUsageStats = false, bool hasPgTableBloatStats = false, bool hasPgSessionStates = false, bool hasPgPlanCaptureReadiness = false, bool hasPgWriteStats = false, bool hasPgExtensionAvailability = false, bool hasPgLockStats = false, bool hasPgColumnStats = false, bool hasPgReplicationStats = false, bool hasPgBufferUsage = false, bool hasPgIndexBloat = false, bool hasPgPerDatabaseAttribution = false, bool hasPgWaitSampling = false, bool hasPgKernelStats = false, bool hasPgPredicateStats = false, bool hasPgPlanCapture = false, bool hasPgMajorVersion = false, bool hasPg18IoBytes = false, bool hasPgServerConfig = false, bool hasPgDeadlocks = false, bool hasPgDeadlockIdentity = false, bool hasCollectorCost = false, bool hasPgCpuUtilization = false, bool hasPlanForceActions = false, bool hasCollectionLogPhaseSplit = false, bool hasCollectionLogDrainForensics = false, bool hasCollectionLogFetchPhaseSums = false, bool hasStoreLogSelfMonitoring = false, bool hasCollectorStallProbes = false, bool hasRemediationCredentialAndActor = false, bool hasPgIndexBloatEstimate = false, bool hasPgCpuCapacityHeadroom = false, bool hasCustomAlertCore = false, bool hasMuteRuleReloadBeacon = false, bool hasBuiltinAlertPersistence = false, bool hasRetentionHoldRatioKnobs = false, bool hasDeadlockRateBandKnobs = false, bool hasOversizedPlanBacklog = false, bool hasPgAlertCountKnobs = false, bool hasFleetSweepState = false, bool hasFleetSweepCadenceKnobs = false, bool hasCollectorScheduleDatabases = false, bool hasSelfDiskWarnGbFloor = false, bool hasDeltaFamilyIntervalColumns = false, bool hasDeltaFamilyIntervalCompletion = false, bool hasPgLogEvents = false, bool hasPgLogEventMetrics = false, bool hasNotificationRoutes = false, bool hasPerfmonCounterType = false, bool hasPgNumbackendsAndSampledMs = false, bool hasTimeHonesty = false, bool hasLrqExclusionKnob = false, bool hasPgDatabaseSizeStatsAndHostMemory = false, bool hasQsCaptureModeRouteKnobToast = false, bool hasPgServerConfigDatabaseRoleOverrides = false, bool hasPostmasterStartTime = false, bool hasCheckpointsTimed = false, bool hasCollectionCaveats = false, bool hasIndexObjectStatsServerTimeIndex = false, bool hasQueryStoreIntervalLatest = false, bool hasRawChunkIntervalRungHistory = false, bool hasQueryStoreIntervalWide = false, bool hasManagedConfVerdicts = false, bool hasComposeTimeoutSixty = false, bool hasReadLatency = false, bool hasHotLivenessTouch = false, bool hasCollectionLogWatermarkAndJobHistoryIndexes = false) { /* V71 (the PostgreSQL blocking-edges rung): a table-existence sentinel and now the newest-first arm. A collector table would ordinarily get no arm at all — see the V63-V69 note below — but the TOP @@ -1205,11 +1211,23 @@ the rung below and showing a spurious upgrade banner on a store that is current. The WPF viewer runs no analysis, so no viewer read names the new table; this arm exists so the version banner stays truthful, which is the only effect the rung has on the viewer. Named only in the probe line, not this prose, per the V71 finding. */ - /* V149 (#4250): the Query Store liveness touch drops query_store_plan_map's last_seen index and - sets fillfactor 90, and now the TOP rung, so a fully-migrated store maps to EXACTLY + /* V150 (#4469, #4477): two supporting indexes, idx_collection_log_watermark and + idx_job_history_server_run, and now the TOP rung, so a fully-migrated store maps to EXACTLY StorageVersion.SchemaVersion rather than falling through to the rung below and showing a spurious upgrade banner on a store that is current. + The WPF viewer runs no analysis, so no viewer read names either index; this arm exists so the + version banner stays truthful, which is the only effect the rung has on the viewer. Named only + in the probe line, not this prose, per the V71 finding. */ + if (hasCollectionLogWatermarkAndJobHistoryIndexes) + { + return 150; + } + + /* V149 (#4250): the Query Store liveness touch drops query_store_plan_map's last_seen index and + sets fillfactor 90. Formerly the TOP rung — RequiredStoreSchemaVersion is StorageVersion.SchemaVersion + and a store below this arm now falls through to V148 instead of stopping here. + The WPF viewer runs no analysis, so no viewer read names this table; this arm exists so the version banner stays truthful, which is the only effect the rung has on the viewer. Named only in the probe line, not this prose, per the V71 finding. */