Skip to content
655 changes: 655 additions & 0 deletions Darling/Darling.Tests/QueryStoreIntervalBrinLiveTests.cs

Large diffs are not rendered by default.

143 changes: 141 additions & 2 deletions Darling/Darling.Tests/QueryStoreIntervalConvergenceLiveTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -478,6 +478,135 @@ public async Task ALegacyRowDatedMoreThanOneHorizonAhead_IsNotArmedOver_WarnsOnc
Assert.Equal(2, FarMaxWarnings(logger));
}

[Fact]
public async Task AFarFutureLegacyRow_AnHourOfBothLoops_RequestsNoLock_WhileAnAutovacuumLikeLockIsHeld()
{
/* #5589. A SHARE UPDATE EXCLUSIVE lock stands in for a running autovacuum: any lock request that conflicts with it would wait
(and in production cancel the autovacuum). With the newest row beyond the horizon, neither the hourly convergence step nor
the background loop's pass (arm, validate, promote, analyze) may request one: every call returns NotReady in well under
the 5 s lock_timeout, and a third connection never sees a backend waiting on the legacy table. */
var baseCs = BaseConnectionString;
Assert.SkipWhen(string.IsNullOrEmpty(baseCs), SkipText);
var ct = TestContext.Current.CancellationToken;
await using var scratch = await ScratchPostgres.CreateAsync(baseCs!, ct);
await using var connection = await OpenStoreAsync(scratch, ct);
await using var loopConnection = await OpenStoreAsync(scratch, ct);
await using var blocker = await OpenStoreAsync(scratch, ct);
await using var watcher = await OpenStoreAsync(scratch, ct);
var logger = new ListLogger();
QueryStoreIntervalPartitions.ResetFarFutureMaxWarnings();

await InsertAsync(connection, Wide, new DateTime(2026, 10, 19, 0, 0, 0, DateTimeKind.Unspecified), 1, ct);

var waiters = 0;
var stop = false;
var steps = new List<(string What, QueryStoreIntervalPartitions.StepOutcome Outcome, TimeSpan Elapsed)>();
Task poll;
await using (var hold = await blocker.BeginTransactionAsync(ct))
{
await ExecAsync(blocker, $"LOCK TABLE {Wide.Legacy} IN SHARE UPDATE EXCLUSIVE MODE", ct);

poll = Task.Run(
async () =>
{
while (!Volatile.Read(ref stop))
{
await using var command = new NpgsqlCommand(
$"SELECT count(*) FROM pg_locks WHERE NOT granted AND relation = '{Wide.Legacy}'::regclass;", watcher);
waiters = Math.Max(waiters, (int)(long)(await command.ExecuteScalarAsync(ct))!);
await Task.Delay(20, ct);
}
},
ct);

/* The hourly convergence step, a few hours running. */
for (var hour = 0; hour < 3; hour++)
{
var watch = System.Diagnostics.Stopwatch.StartNew();
var step = await QueryStoreIntervalPartitions.ConvergeUnpromotedAsync(connection, Wide, Now.AddHours(hour), logger, ct);
steps.Add(("converge", step.Outcome, watch.Elapsed));
}

/* The background loop's own body, through the loop entry: its promotion step is RunPromotionAsync and its analyze step is
AnalyzeIfDueAsync, each on its own connection exactly as the production lambdas do. The index ensure is a no-op here: it
runs once per start, not hourly. */
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
var passes = 0;
await QueryStoreIntervalPartitions.RunDelayedAsync(
logger,
TimeSpan.Zero,
TimeSpan.FromMilliseconds(20),
OnlyWide,
async (table, token) =>
{
var watch = System.Diagnostics.Stopwatch.StartNew();
var result = await QueryStoreIntervalPartitions.RunPromotionAsync(loopConnection, table, Now.AddHours(passes), logger, token);
steps.Add(("promotion", result.Outcome, watch.Elapsed));
return result;
},
async (table, token) =>
{
var watch = System.Diagnostics.Stopwatch.StartNew();
var result = await QueryStoreIntervalPartitions.AnalyzeIfDueAsync(loopConnection, table, Now.AddHours(passes), logger, token);
steps.Add(("analyze", result.Outcome, watch.Elapsed));
if (++passes >= 3)
{
cts.Cancel();
}

return result;
},
_ => Task.CompletedTask,
cts.Token);

Volatile.Write(ref stop, true);
await poll;
await hold.RollbackAsync(ct);
}

Assert.Equal(3, steps.Count(x => x.What == "converge"));
Assert.Equal(3, steps.Count(x => x.What == "promotion"));
Assert.Equal(3, steps.Count(x => x.What == "analyze"));
foreach (var (what, outcome, elapsed) in steps)
{
Assert.True(outcome == QueryStoreIntervalPartitions.StepOutcome.NotReady, $"{what} ended {outcome} (RetryLater means it requested a conflicting lock)");
Assert.True(elapsed < TimeSpan.FromSeconds(3), $"{what} took {elapsed.TotalSeconds:F1} s: it waited on a lock");
}

Assert.Equal(0, waiters);
Assert.Equal(1, FarMaxWarnings(logger));
Assert.False((await StateAsync(connection, ct)).CheckPresent);
}

[Fact]
public async Task ALegacyRowInsideTheHorizon_StillTakesTheArmLock_AndIsRetryLaterWhileAnAutovacuumLikeLockIsHeld()
{
/* #5589. The pre-check is only a refusal: a maximum inside the horizon goes on to the locked, authoritative arm. */
var baseCs = BaseConnectionString;
Assert.SkipWhen(string.IsNullOrEmpty(baseCs), SkipText);
var ct = TestContext.Current.CancellationToken;
await using var scratch = await ScratchPostgres.CreateAsync(baseCs!, ct);
await using var connection = await OpenStoreAsync(scratch, ct);
await using var blocker = await OpenStoreAsync(scratch, ct);
var logger = new ListLogger();
QueryStoreIntervalPartitions.ResetFarFutureMaxWarnings();

await InsertAsync(connection, Wide, new DateTime(2026, 10, 12, 0, 0, 0, DateTimeKind.Unspecified), 1, ct);

await using (var hold = await blocker.BeginTransactionAsync(ct))
{
await ExecAsync(blocker, $"LOCK TABLE {Wide.Legacy} IN SHARE UPDATE EXCLUSIVE MODE", ct);
var blocked = await QueryStoreIntervalPartitions.ConvergeUnpromotedAsync(connection, Wide, Now, logger, ct);
Assert.Equal(QueryStoreIntervalPartitions.StepOutcome.RetryLater, blocked.Outcome);
await hold.RollbackAsync(ct);
}

var armed = await QueryStoreIntervalPartitions.ConvergeUnpromotedAsync(connection, Wide, Now, logger, ct);
Assert.Equal(QueryStoreIntervalPartitions.StepOutcome.Done, armed.Outcome);
Assert.True((await StateAsync(connection, ct)).CheckPresent);
Assert.Equal(0, FarMaxWarnings(logger));
}

[Fact]
public async Task ALegacyRowOneMicrosecondInsideTheHorizon_IsArmedOver_AndTheValidatePasses()
{
Expand Down Expand Up @@ -744,10 +873,20 @@ public void TheRound2Fixes_AreInTheProductText()

/* M1: a far-future legacy maximum is never armed over; the transaction ends before the CHECK is added. */
Assert.True(
arm.IndexOf("IsLegacyMaxBeyondHorizon(", StringComparison.Ordinal) is var m1 && m1 > arm.IndexOf("ReadLegacyMaxAsync(", StringComparison.Ordinal)
arm.LastIndexOf("IsLegacyMaxBeyondHorizon(", StringComparison.Ordinal) is var m1 && m1 > arm.IndexOf("ReadLegacyMaxAsync(", StringComparison.Ordinal)
&& m1 < arm.IndexOf("AddCheckSql(", StringComparison.Ordinal),
"the horizon test sits between the maximum read and the ADD CONSTRAINT");
Assert.Contains("RollbackAsync", arm[arm.IndexOf("IsLegacyMaxBeyondHorizon(", StringComparison.Ordinal)..arm.IndexOf("AddCheckSql(", StringComparison.Ordinal)], StringComparison.Ordinal);
Assert.Contains("RollbackAsync", arm[arm.LastIndexOf("IsLegacyMaxBeyondHorizon(", StringComparison.Ordinal)..arm.IndexOf("AddCheckSql(", StringComparison.Ordinal)], StringComparison.Ordinal);

/* #5589: the same test also runs first, on an unlocked read, so a far-future row never requests ACCESS EXCLUSIVE (it would cancel autovacuum). */
var firstTest = arm.IndexOf("IsLegacyMaxBeyondHorizon(", StringComparison.Ordinal);
Assert.True(firstTest < arm.LastIndexOf("IsLegacyMaxBeyondHorizon(", StringComparison.Ordinal), "the horizon test runs twice");
Assert.True(
arm.IndexOf("ReadLegacyMaxUnlockedAsync(", StringComparison.Ordinal) is var pre && pre > 0
&& pre < firstTest && firstTest < arm.IndexOf("BeginTransactionAsync(", StringComparison.Ordinal)
&& firstTest < arm.IndexOf("ACCESS EXCLUSIVE MODE", StringComparison.Ordinal),
"the unlocked read and its horizon test come before the lock transaction");
Assert.Contains("WarnFarFutureMax(", arm[firstTest..arm.IndexOf("BeginTransactionAsync(", StringComparison.Ordinal)], StringComparison.Ordinal);

/* L1: the guard is derived from the deadline and the re-arm window. */
Assert.Contains("ValidateGuard = TimeSpan.FromSeconds(ValidateTimeoutSeconds) + ReArmWithin;", source, StringComparison.Ordinal);
Expand Down
7 changes: 7 additions & 0 deletions Darling/Darling.Tests/QueryStoreIntervalPartitionRungTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,13 @@ public async Task UpgradeFromV170_WithAValidBtreeAndAValidBrin_AttachesBothWitho
/* The same physical files under the renamed indexes: nothing was rebuilt. */
Assert.Equal(filesBefore, await TextAsync(connection,
"SELECT string_agg(relfilenode::text, ',' ORDER BY replace(relname, '_legacy', '')) FROM pg_class WHERE relname IN ('ux_query_store_interval_wide_legacy', 'ix_query_store_interval_wide_server_first_exec_legacy', 'ix_query_store_interval_wide_collection_time_brin_legacy', 'idx_query_store_interval_wide_first_exec_legacy')", ct));

/* #5594: the parent's BRIN says off, so a day partition made later clones off; the legacy copy keeps what it was built with
(it cannot change without a lock) until the runtime step turns it off. */
Assert.Equal("autosummarize=off", await TextAsync(connection,
"SELECT array_to_string(reloptions, ',') FROM pg_class WHERE oid = 'collect.ix_query_store_interval_wide_collection_time_brin'::regclass", ct));
Assert.Equal("autosummarize=on", await TextAsync(connection,
"SELECT array_to_string(reloptions, ',') FROM pg_class WHERE oid = 'collect.ix_query_store_interval_wide_collection_time_brin_legacy'::regclass", ct));
bodySucceeded = true;
}
finally
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ public async Task Ensure_BuildsAValidBrinIndexOnce_AndASecondCallChangesNothing(
$"SELECT indisvalid FROM pg_index WHERE indexrelid = '{Index}'::regclass", ct))!);
var definition = (string)(await ScalarAsync(connection, $"SELECT pg_get_indexdef('{Index}'::regclass)", ct))!;
Assert.Contains("USING brin (collection_time)", definition);
Assert.Contains("autosummarize='on'", definition);
Assert.Contains("autosummarize=off", definition);

var oid = await ScalarAsync(connection, $"SELECT '{Index}'::regclass::oid", ct);
await EnsureAsync(connection, ct);
Expand Down Expand Up @@ -360,7 +360,7 @@ public async Task AnInvalidLeftover_IsDroppedAndRebuilt()
Assert.NotEqual(oldLeafOid, await ScalarAsync(connection, $"SELECT '{Index}_legacy'::regclass::oid", ct));
var definition = (string)(await ScalarAsync(connection, $"SELECT pg_get_indexdef('{Index}_legacy'::regclass)", ct))!;
Assert.Contains("USING brin (collection_time)", definition);
Assert.Contains("autosummarize='on'", definition);
Assert.Contains("autosummarize=off", definition);
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,10 @@ public void TheBuildSql_IsConcurrent_Idempotent_AndABrinOnCollectionTime()
var sql = QueryStoreIntervalWideBrinIndex.CreateSql;
Assert.Contains("CREATE INDEX CONCURRENTLY IF NOT EXISTS", sql);
Assert.Contains("USING brin (collection_time)", sql);
Assert.Contains("autosummarize = on", sql);
/* #5594: off. An autosummarize work item waits for the table's lock and cancels a running autovacuum. */
Assert.Contains("autosummarize = off", sql);
Assert.DoesNotContain("autosummarize = on", sql);
Assert.Contains("autosummarize = off", QueryStoreIntervalWideBrinIndex.Spec.IndexDefinition);
Assert.Contains("ix_query_store_interval_wide_collection_time_brin", sql);
Assert.Contains("DROP INDEX CONCURRENTLY IF EXISTS", QueryStoreIntervalWideBrinIndex.DropSql);
}
Expand Down
2 changes: 1 addition & 1 deletion Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs
Original file line number Diff line number Diff line change
Expand Up @@ -974,7 +974,7 @@ CREATE TABLE collect.__TABLE__ (LIKE collect.__TABLE___legacy INCLUDING DEFAULTS
.Replace("__DEFINITION__", "(server_id, first_execution_time)", StringComparison.Ordinal)
+ (wide
? optionalIndex.Replace("__NAME__", "ix___TABLE___collection_time_brin", StringComparison.Ordinal)
.Replace("__DEFINITION__", "USING brin (collection_time) WITH (autosummarize = on)", StringComparison.Ordinal)
.Replace("__DEFINITION__", "USING brin (collection_time) WITH (autosummarize = off)", StringComparison.Ordinal)
: @"

CREATE TRIGGER trg_plan_regression_daily_late
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ public enum IndexAction
/// <param name="BelowMinimumReason">Why a server below the floor must not build it, for the log.</param>
/// <param name="IndexDefinition">
/// What follows <c>ON &lt;table&gt;</c> in the index's definition (<c>(server_id, first_execution_time)</c>,
/// <c>USING brin (collection_time) WITH (autosummarize = on)</c>). The partitioned path (#5571) builds the parent
/// <c>USING brin (collection_time) WITH (autosummarize = off)</c>). The partitioned path (#5571) builds the parent
/// <c>ON ONLY</c> and each leaf's child from it. Empty for a spec that is never aimed at a partitioned table.
/// </param>
public sealed record IndexSpec(
Expand Down
Loading
Loading