diff --git a/Darling/Darling.Tests/ManagedConfFileSettingsTests.cs b/Darling/Darling.Tests/ManagedConfFileSettingsTests.cs index ae461dcaf7..43f83239f8 100644 --- a/Darling/Darling.Tests/ManagedConfFileSettingsTests.cs +++ b/Darling/Darling.Tests/ManagedConfFileSettingsTests.cs @@ -6,6 +6,7 @@ * 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.Service; using Xunit; @@ -36,6 +37,60 @@ public void Equal_Matches() Assert.Empty(mismatches); } + private static readonly Dictionary PageCostAddition = new(StringComparer.OrdinalIgnoreCase) + { + ["random_page_cost"] = "1.1", + }; + + [Fact] + public void AllowedAddition_WithTheGivenValue_Matches() + { + var before = new[] { Applied("work_mem", "16MB") }; + var after = new[] { Applied("work_mem", "16MB"), Applied("random_page_cost", "1.1") }; + + Assert.False(ManagedConfFileSettings.Compare(before, after).Match); + var (match, mismatches) = ManagedConfFileSettings.Compare(before, after, PageCostAddition); + + Assert.True(match); + Assert.Empty(mismatches); + } + + [Fact] + public void AllowedAddition_WithAnotherValue_IsAMismatch() + { + var before = new[] { Applied("work_mem", "16MB") }; + var after = new[] { Applied("work_mem", "16MB"), Applied("random_page_cost", "4") }; + + var (match, mismatches) = ManagedConfFileSettings.Compare(before, after, PageCostAddition); + + Assert.False(match); + Assert.Contains("random_page_cost", mismatches); + } + + [Fact] + public void AnyOtherAddedKey_IsStillAMismatch() + { + var before = new[] { Applied("work_mem", "16MB") }; + var after = new[] { Applied("work_mem", "16MB"), Applied("random_page_cost", "1.1"), Applied("shared_buffers", "2048MB") }; + + var (match, mismatches) = ManagedConfFileSettings.Compare(before, after, PageCostAddition); + + Assert.False(match); + Assert.Equal(new[] { "shared_buffers" }, mismatches); + } + + [Fact] + public void AllowedAddition_DoesNotExcuseAChangedValueThatWasAlreadyThere() + { + var before = new[] { Applied("random_page_cost", "4") }; + var after = new[] { Applied("random_page_cost", "1.1") }; + + var (match, mismatches) = ManagedConfFileSettings.Compare(before, after, PageCostAddition); + + Assert.False(match); + Assert.Contains("random_page_cost", mismatches); + } + [Fact] public void ValueChanged_IsAMismatch() { diff --git a/Darling/Darling.Tests/ManagedConfFileTests.cs b/Darling/Darling.Tests/ManagedConfFileTests.cs index 1d0c3766ba..6f420fb30d 100644 --- a/Darling/Darling.Tests/ManagedConfFileTests.cs +++ b/Darling/Darling.Tests/ManagedConfFileTests.cs @@ -9,6 +9,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; @@ -379,6 +380,64 @@ public void RenderBody_ContainsLogLinePrefix() Assert.Contains("log_line_prefix = '%m [%p] %a '", body, StringComparison.Ordinal); } + /// The planner page-cost setting is rendered exactly once, at 1.1, in the managed body and in the + /// full file text a start writes. + [Fact] + public void RenderBody_ContainsRandomPageCost_ExactlyOnce_At1Point1() + { + var inputs = SampleInputs(); + foreach (var text in new[] { ManagedConfFile.RenderBody(inputs), ManagedConfFile.Render(inputs) }) + { + var lines = text.Split('\n').Where(l => l.StartsWith("random_page_cost", StringComparison.Ordinal)).ToList(); + Assert.Single(lines); + Assert.Equal("random_page_cost = '1.1'", lines[0]); + } + } + + /// A file the previous version wrote (the same body without the page-cost line, with a header hash + /// that matches it) is not a hand edit, and a start replaces it with the new render. + [Fact] + public void PreviousVersionRender_IsNotHandEdited_AndIsReplacedWithTheNewLine() + { + var inputs = SampleInputs(); + var newBody = ManagedConfFile.RenderBody(inputs); + var previousBody = string.Concat(newBody.Split('\n', StringSplitOptions.RemoveEmptyEntries) + .Where(l => !l.StartsWith("random_page_cost", StringComparison.Ordinal)) + .Select(l => l + "\n")); + Assert.NotEqual(newBody, previousBody); + var previousFile = ManagedConfFile.RenderHeader(inputs, ManagedConfFile.ComputeBodyHash(previousBody)) + previousBody; + var newFile = ManagedConfFile.Render(inputs); + + Assert.False(ManagedConfFile.IsHandEdited(previousFile)); + Assert.True(ManagedConfFile.ShouldReplaceManagedConf(previousFile, newFile)); + var diff = Assert.Single(ManagedConfFile.DiffBodyKeys( + ManagedConfFile.ParseExisting(previousFile).Body, ManagedConfFile.ParseExisting(newFile).Body)); + Assert.Equal("random_page_cost", diff.Key); + Assert.Null(diff.FileValue); + Assert.Equal("1.1", diff.RenderedValue); + } + + /// A hand-edited previous-version file keeps today's behaviour: it is detected, never replaced, and + /// the diff names the page-cost key as the one a fresh render would add. + [Fact] + public void HandEditedPreviousVersionRender_IsLeftInForce_AndTheDiffNamesRandomPageCost() + { + var inputs = SampleInputs(); + var newFile = ManagedConfFile.Render(inputs); + var previousBody = string.Concat(ManagedConfFile.RenderBody(inputs).Split('\n', StringSplitOptions.RemoveEmptyEntries) + .Where(l => !l.StartsWith("random_page_cost", StringComparison.Ordinal)) + .Select(l => l + "\n")); + var previousFile = ManagedConfFile.RenderHeader(inputs, ManagedConfFile.ComputeBodyHash(previousBody)) + previousBody; + var edited = previousFile.Replace("checkpoint_timeout = '15min'", "checkpoint_timeout = '30min'", StringComparison.Ordinal); + Assert.NotEqual(previousFile, edited); + + Assert.True(ManagedConfFile.IsHandEdited(edited)); + Assert.False(ManagedConfFile.ShouldReplaceManagedConf(edited, newFile)); + var diffs = ManagedConfFile.DiffBodyKeys(ManagedConfFile.ParseExisting(edited).Body, ManagedConfFile.ParseExisting(newFile).Body); + Assert.Contains(diffs, d => d.Key == "random_page_cost" && d.FileValue is null && d.RenderedValue == "1.1"); + Assert.Contains(diffs, d => d.Key == "checkpoint_timeout"); + } + /// Pin: always renders listen_addresses as /// exactly loopback (DarlingManagedPostgres.BuildConfAppend's v1 line) — never the network /// address an exposed store's command line adds. Every verification path that reads this rendered text diff --git a/Darling/Darling.Tests/ManagedConfMigrationRunnerTests.cs b/Darling/Darling.Tests/ManagedConfMigrationRunnerTests.cs index 0322382cac..66418b1b53 100644 --- a/Darling/Darling.Tests/ManagedConfMigrationRunnerTests.cs +++ b/Darling/Darling.Tests/ManagedConfMigrationRunnerTests.cs @@ -106,6 +106,78 @@ public async Task RunStepA_HappyPath_Verifies() Assert.False(File.Exists(Path.Combine(_dataDir, ManagedConfMigrationSteps.PendingFileName))); } + /// Pin: a store still on the legacy blocks reports every legacy key before the migration but not + /// random_page_cost (no legacy block wrote it). The after-snapshot reports it from the managed file. Step A + /// verifies, the file it wrote already carries the line, and a fresh render over that file finds nothing to + /// replace, so the next start leaves the file alone. + [Fact] + public async Task RunStepA_LegacyStoreWithoutPageCost_ConvergesInOneStart() + { + WriteConf("# base conf\n"); + var inputs = SampleInputs(); + var derived = DerivedValues(inputs); + Assert.Equal("1.1", derived["random_page_cost"]); + + var beforeRows = new List(); + foreach (var kvp in derived) + { + if (kvp.Key != "random_page_cost") + { + beforeRows.Add(Applied(kvp.Key, kvp.Value)); + } + } + + var afterRows = new List(beforeRows) { Applied("random_page_cost", "1.1", ManagedConfFile.FileName) }; + var calls = 0; + Func>> snapshot = _ => + Task.FromResult>(++calls == 1 ? beforeRows : afterRows); + + var outcome = await ManagedConfMigrationRunner.RunStepA( + _dataDir, snapshot, derived, inputs, inputs.Port, UtcNow, new CapturingTestLogger(), CancellationToken.None); + + Assert.Equal(ManagedConfVerificationStatus.Verified, outcome.Status); + Assert.Empty(outcome.MismatchedKeys); + var written = File.ReadAllText(Path.Combine(_dataDir, ManagedConfFile.FileName)); + Assert.Contains("random_page_cost = '1.1'", written, StringComparison.Ordinal); + Assert.False(ManagedConfFile.IsHandEdited(written)); + Assert.False(ManagedConfFile.ShouldReplaceManagedConf(written, ManagedConfFile.Render(inputs))); + } + + /// Pin: only the page-cost addition is accepted. If the after-snapshot also gains any other key the + /// before did not have, Step A still fails and restores. + [Fact] + public async Task RunStepA_LegacyStoreWithoutPageCost_AnotherAddedKey_StillFails() + { + WriteConf("# base conf\n"); + var inputs = SampleInputs(); + var derived = DerivedValues(inputs); + + var beforeRows = new List(); + foreach (var kvp in derived) + { + if (kvp.Key is not ("random_page_cost" or "work_mem")) + { + beforeRows.Add(Applied(kvp.Key, kvp.Value)); + } + } + + var afterRows = new List(beforeRows) + { + Applied("random_page_cost", "1.1", ManagedConfFile.FileName), + Applied("work_mem", derived["work_mem"], ManagedConfFile.FileName), + }; + var calls = 0; + Func>> snapshot = _ => + Task.FromResult>(++calls == 1 ? beforeRows : afterRows); + + var outcome = await ManagedConfMigrationRunner.RunStepA( + _dataDir, snapshot, derived, inputs, inputs.Port, UtcNow, new CapturingTestLogger(), CancellationToken.None); + + Assert.Equal(ManagedConfVerificationStatus.Failed, outcome.Status); + Assert.Equal(new[] { "work_mem" }, outcome.MismatchedKeys); + Assert.False(ManagedConfMigrationSteps.IsVerified(_dataDir)); + } + /// Pin: a mismatch — the after-snapshot disagrees with the before on one key. The conf is /// byte-equal to the backup, no stamp, and Failed lists the key. [Fact] diff --git a/Darling/Darling.Tests/ManagedConfRehearsalTests.cs b/Darling/Darling.Tests/ManagedConfRehearsalTests.cs index d92e480bae..e42de80fe7 100644 --- a/Darling/Darling.Tests/ManagedConfRehearsalTests.cs +++ b/Darling/Darling.Tests/ManagedConfRehearsalTests.cs @@ -27,13 +27,13 @@ namespace Darling.Tests; /// stay exactly where it is); and log_timezone = 'UTC' at the end (a key no managed block owns, so it /// stays exactly where it is too). /// -/// This fixture's numbers are 69 lines removed / 26 managed keys / 1 operator line moved. +/// This fixture's numbers are 69 lines removed / 27 managed keys / 1 operator line moved. /// The "1 operator line moved" is exactly what the rewrite logic requires. The counts below explain why they -/// are 69/26 rather than some other plausible pair, given how today's builders emit their blocks: +/// are 69/27 rather than some other plausible pair, given how today's builders emit their blocks: /// -/// managed keys 26 vs 23. emits one line per key +/// managed keys 27 vs 23. emits one line per key /// reads back from calling every Build*ConfAppend in -/// order with THIS PR's inputs (RAM authoritative, disk authoritative, PostgreSQL 18) — today that is 26 keys. +/// order with THIS PR's inputs (RAM authoritative, disk authoritative, PostgreSQL 18) — today that is 27 keys. /// One extra key is min_wal_size: v12's WAL-sizing block /// () writes both max_wal_size AND /// min_wal_size, and nothing later in the v1-v14 order overwrites the second one, so it survives the @@ -42,7 +42,10 @@ namespace Darling.Tests; /// too, and nothing else in this fixture's inputs sets that key. The /// last extra key is log_line_prefix: v17 /// () is appended in -/// too, and nothing else in this fixture's inputs sets that key either. +/// too, and nothing else in this fixture's inputs sets that key +/// either. The newest key is random_page_cost: the planner page-cost block +/// () has no legacy block, so the fixture's +/// stock conf never carries it and it adds a managed key but no removed line. /// lines removed 69. The initdb stock conf, v1-v14 /// each appended once except v8 four times, the three hand edits — this fixture reproduces, so the shapes /// match. The absolute REMOVAL count is a function of how many total OURS lines the classifier drops (three @@ -112,7 +115,7 @@ private static string FindRepoRootedFile(string relativePath) throw new FileNotFoundException($"Could not locate '{relativePath}' walking up from '{AppContext.BaseDirectory}'."); } - /// The rehearsal's headline numbers (see the class doc comment for why 69/26 rather than the + /// The rehearsal's headline numbers (see the class doc comment for why 69/27 rather than the /// design's 48/23): lines removed, managed keys in the map handed to Rewrite, and operator lines /// moved below the include — the last of which holds exactly at 1, as required. [Fact] @@ -127,7 +130,7 @@ public void Rewrite_RehearsalFixture_LinesRemovedManagedKeysAndOperatorLinesMove var afterLineCount = result.NewConfText.Split('\n').Length; Assert.Equal(69, beforeLineCount - afterLineCount); - Assert.Equal(26, managedValues.Count); + Assert.Equal(27, managedValues.Count); Assert.Single(result.ExcludedKeys); Assert.Contains("max_connections", result.ExcludedKeys); } diff --git a/Darling/PerformanceMonitor.Darling.Service/DarlingManagedPostgres.cs b/Darling/PerformanceMonitor.Darling.Service/DarlingManagedPostgres.cs index 1e1cf9cf5c..9bd4fa2a99 100644 --- a/Darling/PerformanceMonitor.Darling.Service/DarlingManagedPostgres.cs +++ b/Darling/PerformanceMonitor.Darling.Service/DarlingManagedPostgres.cs @@ -2374,6 +2374,51 @@ public static string BuildLogLinePrefixConfAppend() return builder.ToString(); } + /* ===================== planner page cost ===================== */ + + /// The setting writes. + internal const string RandomPageCostSetting = "random_page_cost"; + + /// The value writes: 1.1. + internal const string RandomPageCostValue = "1.1"; + + /// + /// The planner page-cost block: random_page_cost = 1.1 only. Rendered into + /// darling-managed.conf by and NOT appended to + /// postgresql.conf as a numbered legacy block: it has no marker. A store whose conf still carries the + /// legacy blocks gets the line in darling-managed.conf when that conf migrates + /// (), written at the same start as the rest of the file, so the + /// next start finds the file unchanged. That migration runs after the start and never reloads, so on such a + /// store the value is in force from the following start; every store already on the managed file has the line + /// written before pg_ctl start, so it is in force on the start that writes it. + /// + /// Why. The store lives on SSD-backed volumes (EBS gp3 at 3,000 to 6,000 provisioned IOPS in the + /// measured case). With PostgreSQL's default of 4 the planner prices a BRIN index's lossy heap pages as + /// random reads, and on a large production store it chose a sequential scan over the whole + /// query_store_interval_wide table for a 12-hour window (3.2 million blocks) even with a BRIN index + /// on collection_time. SET LOCAL random_page_cost = 1.1 moved the 6-hour, 12-hour and 48-hour + /// windows onto the BRIN index: a 24-hour read took 4.3 s warm (about 37 s cold, at the volume's throughput cap) instead of 98.8 s cold on the full scan. + /// + /// Why 1.1 and not 1.0. seq_page_cost stays at its default of 1.0. The PostgreSQL 18 + /// documentation says the default of 4.0 assumes most random reads (indexed reads) are cached, that + /// network-attached storage latency shrinks the relative cost of random access, and that decreasing the + /// value is appropriate when data is largely cached or latency is high; it also says a value below + /// seq_page_cost is not physically sensible, and that setting the two equal only makes sense for a + /// database entirely in RAM. This store is on SSD-backed network volumes and is not entirely cached, so it + /// stays just above seq_page_cost. 1.1 is the measured figure, not a documented one. + /// + /// random_page_cost is user-context, so a reload applies it. A value set + /// with ALTER SYSTEM lives in postgresql.auto.conf and wins over this file; nothing here reads + /// or writes that file. + /// + public static string BuildPlannerPageCostConfAppend() + { + var builder = new StringBuilder(); + builder.Append('\n'); + builder.Append(RandomPageCostSetting).Append(" = ").Append(RandomPageCostValue).Append('\n'); + return builder.ToString(); + } + /* ===================== v12 wal sizing (derived from data-volume headroom, #3802) ===================== */ /// 1 GB — the floor under the derived max_wal_size, and PostgreSQL's own default for it: diff --git a/Darling/PerformanceMonitor.Darling.Service/ManagedConfFile.cs b/Darling/PerformanceMonitor.Darling.Service/ManagedConfFile.cs index 9103d78168..fe1eac2e68 100644 --- a/Darling/PerformanceMonitor.Darling.Service/ManagedConfFile.cs +++ b/Darling/PerformanceMonitor.Darling.Service/ManagedConfFile.cs @@ -233,6 +233,9 @@ from starting. NeedsLegacyMaintenanceWorkMemCap is the same pure predicate Ensur /* v17: a fixed log_line_prefix, appended in the same order as the legacy blocks above. */ blocks.Append(DarlingManagedPostgres.BuildLogLinePrefixConfAppend()); + /* Planner page cost: no legacy block and no marker; this file is the only place the setting is written. */ + blocks.Append(DarlingManagedPostgres.BuildPlannerPageCostConfAppend()); + var (order, values) = ReduceToLastOccurrence(blocks.ToString()); var body = new StringBuilder(); foreach (var key in order) @@ -245,6 +248,18 @@ from starting. NeedsLegacyMaintenanceWorkMemCap is the same pure predicate Ensur return body.ToString(); } + /// + /// The owned keys no legacy postgresql.conf block ever wrote, with the value + /// derives for each: only darling-managed.conf carries them. writes + /// that value when the before-snapshot has none, and is given + /// the same map so Step A's verification accepts exactly that addition and nothing else. + /// + internal static readonly IReadOnlyDictionary ManagedOnlyKeys = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [DarlingManagedPostgres.RandomPageCostSetting] = DarlingManagedPostgres.RandomPageCostValue, + }; + /// /// Renders darling-managed.conf the way Step A's post-start verification needs (#4336): /// the SAME owned keys and order would produce for @@ -252,7 +267,8 @@ from starting. NeedsLegacyMaintenanceWorkMemCap is the same pure predicate Ensur /// text pg_file_settings reports as applied for that key — instead of the freshly derived /// one. An owned key would have written that is missing from /// is dropped from the body entirely (it stays at whatever default is already - /// in force; Step A never invents a value the snapshot did not report). + /// in force; Step A never invents a value the snapshot did not report), except a + /// key, which is written at its derived value. /// (#4336) carries the keys the rewritten postgresql.conf lost that this render does not own — /// for example v12's min_wal_size when the disk reading is not authoritative this start — each /// written from 's snapshot value, in the order given, after every owned key; @@ -267,7 +283,7 @@ internal static string RenderWithValues( IReadOnlyList? extraKeys = null) { var derivedBody = RenderBody(inputs); - var (order, _) = ReduceToLastOccurrence(derivedBody); + var (order, derivedValues) = ReduceToLastOccurrence(derivedBody); var body = new StringBuilder(); var written = new HashSet(StringComparer.OrdinalIgnoreCase); @@ -280,6 +296,16 @@ internal static string RenderWithValues( .Append("'\n"); written.Add(key); } + else if (ManagedOnlyKeys.ContainsKey(key)) + { + /* No legacy block ever wrote this key, so a legacy store's snapshot never has it. Dropping it here + would leave the migrated file one line short of what the next start renders, and that start + would rewrite the file. Its derived value is written instead. */ + body.Append(key).Append(" = '") + .Append(DarlingManagedPostgres.EscapeConfValue(derivedValues[key])) + .Append("'\n"); + written.Add(key); + } } if (extraKeys is not null) diff --git a/Darling/PerformanceMonitor.Darling.Service/ManagedConfFileSettings.cs b/Darling/PerformanceMonitor.Darling.Service/ManagedConfFileSettings.cs index f57c06012b..8e6e54d187 100644 --- a/Darling/PerformanceMonitor.Darling.Service/ManagedConfFileSettings.cs +++ b/Darling/PerformanceMonitor.Darling.Service/ManagedConfFileSettings.cs @@ -53,10 +53,15 @@ internal static class ManagedConfFileSettings /// touch stays exactly as broken as it always was. A NEW error row in — a name /// with no error row in that gained one — is always a mismatch: Step A must /// never make a previously-clean setting start failing to parse. + /// + /// names settings the migration itself adds: a name with no + /// applied row in whose applied row in carries exactly + /// the given value is not a mismatch. Any other value, and any name not in the map, still is. /// internal static (bool Match, IReadOnlyList Mismatches) Compare( IReadOnlyList before, - IReadOnlyList after) + IReadOnlyList after, + IReadOnlyDictionary? allowedAdditions = null) { var beforeApplied = AppliedByName(before); var afterApplied = AppliedByName(after); @@ -88,6 +93,13 @@ internal static (bool Match, IReadOnlyList Mismatches) Compare( } // In afterApplied but not beforeApplied (the loop above only adds names it saw in beforeApplied). + if (allowedAdditions is not null + && allowedAdditions.TryGetValue(name, out var allowedValue) + && string.Equals(afterApplied[name], allowedValue, StringComparison.Ordinal)) + { + continue; + } + mismatches.Add(name); } diff --git a/Darling/PerformanceMonitor.Darling.Service/ManagedConfMigrationRunner.cs b/Darling/PerformanceMonitor.Darling.Service/ManagedConfMigrationRunner.cs index 847471d52c..f0a4f07e18 100644 --- a/Darling/PerformanceMonitor.Darling.Service/ManagedConfMigrationRunner.cs +++ b/Darling/PerformanceMonitor.Darling.Service/ManagedConfMigrationRunner.cs @@ -280,7 +280,7 @@ private static ManagedConfMigrationOutcome CompareAndFinish( IReadOnlyList before, IReadOnlyList after) { - var (match, mismatches) = ManagedConfFileSettings.Compare(before, after); + var (match, mismatches) = ManagedConfFileSettings.Compare(before, after, ManagedConfFile.ManagedOnlyKeys); if (match) { diff --git a/Darling/README.md b/Darling/README.md index 34cb4c0771..c040d0bc2f 100644 --- a/Darling/README.md +++ b/Darling/README.md @@ -37,7 +37,7 @@ Nothing is installed on the monitored SQL Servers by either edition beyond two l ### Prerequisites - **Windows** for the service host (Windows-service lifetime, DPAPI password protection) and for the viewer (WPF). Monitored servers can be SQL Server 2016 SP2–2025 (2017 needs CU3 or later), Azure SQL Managed Instance, AWS RDS for SQL Server, or Azure SQL Database. -- **A PostgreSQL store — bundled or your own.** In managed mode (the shipped default, see [Managed Bundled PostgreSQL](#managed-bundled-postgresql)) the service runs its own bundled PostgreSQL 18 + TimescaleDB and no database provisioning is needed. To bring your own instead, PostgreSQL 16 or newer is recommended (developed and validated against PostgreSQL 18) with a database and a login the service can create tables in — and if that store has TimescaleDB, size its background workers before you rely on compression, because the stock PostgreSQL defaults cannot run the policies (see [Background workers](#background-workers-sizing-an-unmanaged-store-and-what-happens-if-you-dont)). +- **A PostgreSQL store — bundled or your own.** In managed mode (the shipped default, see [Managed Bundled PostgreSQL](#managed-bundled-postgresql)) the service runs its own bundled PostgreSQL 18 + TimescaleDB and no database provisioning is needed. To bring your own instead, PostgreSQL 16 or newer is recommended (developed and validated against PostgreSQL 18) with a database and a login the service can create tables in — and if that store has TimescaleDB, size its background workers before you rely on compression, because the stock PostgreSQL defaults cannot run the policies (see [Background workers](#background-workers-sizing-an-unmanaged-store-and-what-happens-if-you-dont)). On SSD-backed storage, also set `random_page_cost = 1.1` (the managed store does): with the default of 4 the planner prices a BRIN index's heap reads as random I/O and can pick a sequential scan over the index for windows of 12 hours or more. The BRIN index on the per-interval Query Store table is skipped below PostgreSQL 16, because HOT updates past a BRIN index need PostgreSQL 16 or newer. - **TimescaleDB is optional and auto-adopted.** If the extension is installed (or pre-created by an administrator) in the store database, the service detects it at startup and automatically converts the collector tables to hypertables with compression; without it, the service runs in plain-PostgreSQL mode, which is fully supported. No configuration flag either way. The detection is also **re-run hourly for as long as it says no** (#3815), so a service that fell back because the store was restarting, failing over or momentarily unreachable at its own start returns to hypertable mode within the hour instead of at the next restart. - **Two .NET 10 runtimes on the host**, from . Both shipped binaries are framework-dependent, and a stock Windows Server image has neither: