Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
55 changes: 55 additions & 0 deletions Darling/Darling.Tests/ManagedConfFileSettingsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -36,6 +37,60 @@ public void Equal_Matches()
Assert.Empty(mismatches);
}

private static readonly Dictionary<string, string> 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()
{
Expand Down
59 changes: 59 additions & 0 deletions Darling/Darling.Tests/ManagedConfFileTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -379,6 +380,64 @@ public void RenderBody_ContainsLogLinePrefix()
Assert.Contains("log_line_prefix = '%m [%p] %a '", body, StringComparison.Ordinal);
}

/// <summary>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.</summary>
[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]);
}
}

/// <summary>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.</summary>
[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);
}

/// <summary>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.</summary>
[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");
}

/// <summary>Pin: <see cref="ManagedConfFile.RenderBody"/> always renders <c>listen_addresses</c> as
/// exactly loopback (<c>DarlingManagedPostgres.BuildConfAppend</c>'s v1 line) — never the network
/// address an exposed store's command line adds. Every verification path that reads this rendered text
Expand Down
72 changes: 72 additions & 0 deletions Darling/Darling.Tests/ManagedConfMigrationRunnerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,78 @@ public async Task RunStepA_HappyPath_Verifies()
Assert.False(File.Exists(Path.Combine(_dataDir, ManagedConfMigrationSteps.PendingFileName)));
}

/// <summary>Pin: a store still on the legacy blocks reports every legacy key before the migration but not
/// <c>random_page_cost</c> (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.</summary>
[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<FileSettingRow>();
foreach (var kvp in derived)
{
if (kvp.Key != "random_page_cost")
{
beforeRows.Add(Applied(kvp.Key, kvp.Value));
}
}

var afterRows = new List<FileSettingRow>(beforeRows) { Applied("random_page_cost", "1.1", ManagedConfFile.FileName) };
var calls = 0;
Func<CancellationToken, Task<IReadOnlyList<FileSettingRow>>> snapshot = _ =>
Task.FromResult<IReadOnlyList<FileSettingRow>>(++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)));
}

/// <summary>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.</summary>
[Fact]
public async Task RunStepA_LegacyStoreWithoutPageCost_AnotherAddedKey_StillFails()
{
WriteConf("# base conf\n");
var inputs = SampleInputs();
var derived = DerivedValues(inputs);

var beforeRows = new List<FileSettingRow>();
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<FileSettingRow>(beforeRows)
{
Applied("random_page_cost", "1.1", ManagedConfFile.FileName),
Applied("work_mem", derived["work_mem"], ManagedConfFile.FileName),
};
var calls = 0;
Func<CancellationToken, Task<IReadOnlyList<FileSettingRow>>> snapshot = _ =>
Task.FromResult<IReadOnlyList<FileSettingRow>>(++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));
}

/// <summary>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.</summary>
[Fact]
Expand Down
17 changes: 10 additions & 7 deletions Darling/Darling.Tests/ManagedConfRehearsalTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,13 @@ namespace Darling.Tests;
/// stay exactly where it is); and <c>log_timezone = 'UTC'</c> at the end (a key no managed block owns, so it
/// stays exactly where it is too).
///
/// <para><b>This fixture's numbers</b> are 69 lines removed / 26 managed keys / 1 operator line moved.
/// <para><b>This fixture's numbers</b> 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:
/// <list type="bullet">
/// <item><b>managed keys 26 vs 23.</b> <see cref="ManagedConfFile.RenderBody"/> emits one line per key
/// <item><b>managed keys 27 vs 23.</b> <see cref="ManagedConfFile.RenderBody"/> emits one line per key
/// <see cref="DarlingManagedPostgres.ParseConfText"/> reads back from calling every <c>Build*ConfAppend</c> 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 <c>min_wal_size</c>: v12's WAL-sizing block
/// (<see cref="DarlingManagedPostgres.BuildWalSizingConfAppend"/>) writes both <c>max_wal_size</c> AND
/// <c>min_wal_size</c>, and nothing later in the v1-v14 order overwrites the second one, so it survives the
Expand All @@ -42,7 +42,10 @@ namespace Darling.Tests;
/// <see cref="ManagedConfFile.RenderBody"/> too, and nothing else in this fixture's inputs sets that key. The
/// last extra key is <c>log_line_prefix</c>: v17
/// (<see cref="DarlingManagedPostgres.BuildLogLinePrefixConfAppend"/>) is appended in
/// <see cref="ManagedConfFile.RenderBody"/> too, and nothing else in this fixture's inputs sets that key either.</item>
/// <see cref="ManagedConfFile.RenderBody"/> too, and nothing else in this fixture's inputs sets that key
/// either. The newest key is <c>random_page_cost</c>: the planner page-cost block
/// (<see cref="DarlingManagedPostgres.BuildPlannerPageCostConfAppend"/>) has no legacy block, so the fixture's
/// stock conf never carries it and it adds a managed key but no removed line.</item>
/// <item><b>lines removed 69.</b> 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
Expand Down Expand Up @@ -112,7 +115,7 @@ private static string FindRepoRootedFile(string relativePath)
throw new FileNotFoundException($"Could not locate '{relativePath}' walking up from '{AppContext.BaseDirectory}'.");
}

/// <summary>The rehearsal's headline numbers (see the class doc comment for why 69/26 rather than the
/// <summary>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 <c>Rewrite</c>, and operator lines
/// moved below the include — the last of which holds exactly at 1, as required.</summary>
[Fact]
Expand All @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2374,6 +2374,51 @@ public static string BuildLogLinePrefixConfAppend()
return builder.ToString();
}

/* ===================== planner page cost ===================== */

/// <summary>The setting <see cref="BuildPlannerPageCostConfAppend"/> writes.</summary>
internal const string RandomPageCostSetting = "random_page_cost";

/// <summary>The value <see cref="BuildPlannerPageCostConfAppend"/> writes: <c>1.1</c>.</summary>
internal const string RandomPageCostValue = "1.1";

/// <summary>
/// The planner page-cost block: <c>random_page_cost = 1.1</c> only. Rendered into
/// <c>darling-managed.conf</c> by <see cref="ManagedConfFile.RenderBody"/> and NOT appended to
/// <c>postgresql.conf</c> as a numbered legacy block: it has no marker. A store whose conf still carries the
/// legacy blocks gets the line in <c>darling-managed.conf</c> when that conf migrates
/// (<see cref="ManagedConfFile.ManagedOnlyKeys"/>), 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 <c>pg_ctl start</c>, so it is in force on the start that writes it.
///
/// <para><b>Why.</b> 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
/// <c>query_store_interval_wide</c> table for a 12-hour window (3.2 million blocks) even with a BRIN index
/// on <c>collection_time</c>. <c>SET LOCAL random_page_cost = 1.1</c> 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.</para>
///
/// <para><b>Why 1.1 and not 1.0.</b> <c>seq_page_cost</c> 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
/// <c>seq_page_cost</c> 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 <c>seq_page_cost</c>. 1.1 is the measured figure, not a documented one.</para>
///
/// <para><c>random_page_cost</c> is <c>user</c>-context, so a reload applies it. A value set
/// with <c>ALTER SYSTEM</c> lives in <c>postgresql.auto.conf</c> and wins over this file; nothing here reads
/// or writes that file.</para>
/// </summary>
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) ===================== */

/// <summary>1 GB — the floor under the derived <c>max_wal_size</c>, and PostgreSQL's own default for it:
Expand Down
Loading
Loading