Repository navigation
Stop learning a mid-restore state as a database's expected one (#2189) #2196
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,152 @@ | ||
| /* | ||
| * 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.Globalization; | ||
| using System.Threading; | ||
| using System.Threading.Tasks; | ||
| using Npgsql; | ||
| using PerformanceMonitor.Darling.Service; | ||
| using PerformanceMonitor.Darling.Storage; | ||
| using Xunit; | ||
|
|
||
| namespace Darling.Tests; | ||
|
|
||
| /// <summary> | ||
| /// #2189 gated-live contract for <see cref="DarlingAlertReadAdapter.RepairTransientDatabaseStateBaselineSql"/>. | ||
| /// | ||
| /// <para>The bug: the seed excluded only SUSPECT / RECOVERY_PENDING / EMERGENCY, so a database observed | ||
| /// mid-restore had RESTORING recorded as its ACCEPTED baseline and then deviated permanently by being | ||
| /// healthy. Measured on the production fleet: 636 alerts in 24 hours from 5 databases, every one reading | ||
| /// "Expected: RESTORING, Current: ONLINE". Excluding those states from the seed stops new ones; it cannot | ||
| /// repair rows already written, because the seed is insert-if-absent by design.</para> | ||
| /// | ||
| /// <para>Live rather than harness-level because the whole statement IS the behaviour — there is no C# to | ||
| /// exercise. Runs against a real Postgres gated on <c>DARLING_TEST_PG</c>, on the serialized | ||
| /// "live-postgres" collection, against a negative sentinel server_id, cleaning up in finally.</para> | ||
| /// </summary> | ||
| [Collection("live-postgres")] | ||
| public sealed class TransientBaselineRepairLiveTests | ||
| { | ||
| private const int LiveServerId = -915759; | ||
|
|
||
| [Fact] | ||
| public async Task Repair_DropsGuessedTransientBaselines_KeepsSteadyStatesAndOperatorIntent() | ||
| { | ||
| 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 transient-baseline repair."); | ||
|
|
||
| var ct = TestContext.Current.CancellationToken; | ||
| using var connection = new NpgsqlConnection(connectionString); | ||
| await connection.OpenAsync(ct); | ||
| await PgMigrations.MigrateAsync(connection, ct); | ||
| await DeleteLiveRowsAsync(connection, ct); | ||
|
|
||
| var bodySucceeded = false; | ||
| try | ||
| { | ||
| /* The bug's signature: a guessed baseline naming a state nobody would choose as "expected". */ | ||
| await ExpectedAsync(connection, ct, "MidRestore", "RESTORING", userOverride: false); | ||
| await ExpectedAsync(connection, ct, "MidRecovery", "RECOVERING", userOverride: false); | ||
| await ExpectedAsync(connection, ct, "FirstSeenSuspect", "SUSPECT", userOverride: false); | ||
|
|
||
| /* Steady states a baseline SHOULD hold. STANDBY is the one worth pinning explicitly: a | ||
| log-shipping secondary sits there permanently, so it is a legitimate expectation and must not | ||
| be swept along with the transient states it superficially resembles. */ | ||
| await ExpectedAsync(connection, ct, "Healthy", "ONLINE", userOverride: false); | ||
| await ExpectedAsync(connection, ct, "LogShipped", "STANDBY", userOverride: false); | ||
|
|
||
| /* Operator intent, even when it names a transient state: somebody who deliberately expects a | ||
| database to sit in RESTORING means it, and their row must survive. */ | ||
| await ExpectedAsync(connection, ct, "DeliberatelyRestoring", "RESTORING", userOverride: true); | ||
|
|
||
| using (var repair = new NpgsqlCommand(DarlingAlertReadAdapter.RepairTransientDatabaseStateBaselineSql, connection)) | ||
| { | ||
| repair.Parameters.AddWithValue(LiveServerId); | ||
| Assert.Equal(3, await repair.ExecuteNonQueryAsync(ct)); | ||
| } | ||
|
|
||
| Assert.Null(await ExpectedStateAsync(connection, ct, "MidRestore")); | ||
| Assert.Null(await ExpectedStateAsync(connection, ct, "MidRecovery")); | ||
| Assert.Null(await ExpectedStateAsync(connection, ct, "FirstSeenSuspect")); | ||
|
|
||
| Assert.Equal("ONLINE", await ExpectedStateAsync(connection, ct, "Healthy")); | ||
| Assert.Equal("STANDBY", await ExpectedStateAsync(connection, ct, "LogShipped")); | ||
| Assert.Equal("RESTORING", await ExpectedStateAsync(connection, ct, "DeliberatelyRestoring")); | ||
|
|
||
| /* Idempotent: it runs on every evaluation of every server, so a second pass over an already | ||
| repaired store must change nothing. */ | ||
| using (var again = new NpgsqlCommand(DarlingAlertReadAdapter.RepairTransientDatabaseStateBaselineSql, connection)) | ||
| { | ||
| again.Parameters.AddWithValue(LiveServerId); | ||
| Assert.Equal(0, await again.ExecuteNonQueryAsync(ct)); | ||
| } | ||
|
|
||
| bodySucceeded = true; | ||
| } | ||
| finally | ||
| { | ||
| await LiveStoreCleanup.RunAsync(connectionString!, bodySucceeded, async (cleanup, cleanupCt) => | ||
| await DeleteLiveRowsAsync(cleanup, cleanupCt)); | ||
| } | ||
| } | ||
|
|
||
| [Fact] | ||
| public void Seed_ExcludesEveryStateTheRepairDeletes() | ||
| { | ||
| /* The two statements have to agree, or the pair oscillates: the seed writes a baseline the repair | ||
| deletes on the next evaluation, forever, churning the table and re-announcing the database. This | ||
| pins that the seed's exclusion list covers everything the repair treats as unfit. */ | ||
| foreach (var state in new[] { "SUSPECT", "RECOVERY_PENDING", "EMERGENCY", "RESTORING", "RECOVERING" }) | ||
| { | ||
| Assert.Contains($"'{state}'", DarlingAlertReadAdapter.RepairTransientDatabaseStateBaselineSql, StringComparison.Ordinal); | ||
| Assert.Contains($"'{state}'", DarlingAlertReadAdapter.SeedDatabaseStateExpectedSql, StringComparison.Ordinal); | ||
| } | ||
|
|
||
| /* And the inverse: a steady state must appear in NEITHER list. STANDBY is the trap. */ | ||
| Assert.DoesNotContain("'STANDBY'", DarlingAlertReadAdapter.RepairTransientDatabaseStateBaselineSql, StringComparison.Ordinal); | ||
| Assert.DoesNotContain("'ONLINE'", DarlingAlertReadAdapter.RepairTransientDatabaseStateBaselineSql, StringComparison.Ordinal); | ||
|
|
||
| /* Operator intent is the repair's only exemption, so its absence would be silent data loss. */ | ||
| Assert.Contains("is_user_override = false", DarlingAlertReadAdapter.RepairTransientDatabaseStateBaselineSql, StringComparison.Ordinal); | ||
| } | ||
|
|
||
| private static async Task ExpectedAsync( | ||
| NpgsqlConnection connection, CancellationToken ct, string database, string expected, bool userOverride) | ||
| { | ||
| using var command = new NpgsqlCommand(@" | ||
| INSERT INTO config.database_state_expected (server_id, database_name, expected_state, is_user_override, updated_at) | ||
| VALUES ($1, $2, $3, $4, (now() AT TIME ZONE 'UTC'))", connection); | ||
| command.Parameters.AddWithValue(LiveServerId); | ||
| command.Parameters.AddWithValue(database); | ||
| command.Parameters.AddWithValue(expected); | ||
| command.Parameters.AddWithValue(userOverride); | ||
| await command.ExecuteNonQueryAsync(ct); | ||
| } | ||
|
|
||
| private static async Task<string?> ExpectedStateAsync( | ||
| NpgsqlConnection connection, CancellationToken ct, string database) | ||
| { | ||
| using var command = new NpgsqlCommand( | ||
| "SELECT expected_state FROM config.database_state_expected WHERE server_id = $1 AND database_name = $2", | ||
| connection); | ||
| command.Parameters.AddWithValue(LiveServerId); | ||
| command.Parameters.AddWithValue(database); | ||
| var value = await command.ExecuteScalarAsync(ct); | ||
| return value is DBNull or null ? null : (string)value; | ||
| } | ||
|
|
||
| private static async Task DeleteLiveRowsAsync(NpgsqlConnection connection, CancellationToken ct) | ||
| { | ||
| using var cleanup = new NpgsqlCommand( | ||
| $"DELETE FROM config.database_state_expected WHERE server_id = {LiveServerId.ToString(CultureInfo.InvariantCulture)};", | ||
| connection); | ||
| await cleanup.ExecuteNonQueryAsync(ct); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -680,9 +680,37 @@ FROM database_states ds | |
| WHERE ds.server_id = $1 | ||
| AND ds.collection_time = (SELECT MAX(collection_time) FROM database_states WHERE server_id = $1) | ||
| AND ds.state_desc IS NOT NULL | ||
| AND (CASE WHEN ds.is_in_standby THEN 'STANDBY' ELSE ds.state_desc END) NOT IN ('SUSPECT', 'RECOVERY_PENDING', 'EMERGENCY') | ||
| AND (CASE WHEN ds.is_in_standby THEN 'STANDBY' ELSE ds.state_desc END) NOT IN ('SUSPECT', 'RECOVERY_PENDING', 'EMERGENCY', 'RESTORING', 'RECOVERING') | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Parity gap: This PR updates AND (CASE WHEN ds.is_in_standby THEN 'STANDBY' ELSE ds.state_desc END) NOT IN ('SUSPECT', 'RECOVERY_PENDING', 'EMERGENCY')Still excludes only the three original states — RESTORING/RECOVERING are not excluded there, and this file never calls the new Concretely: Worth applying the same exclusion list + repair call there for it to actually close the bug everywhere it can be introduced. |
||
| ON CONFLICT (server_id, database_name) DO NOTHING"; | ||
|
|
||
| /// <summary> | ||
| /// #2189: removes an auto-baseline that recorded a TRANSIENT state as expected, so the seed above can | ||
| /// re-learn the database's real steady state on its next pass. | ||
| /// | ||
| /// <para>The seed used to exclude only SUSPECT / RECOVERY_PENDING / EMERGENCY, so a database observed | ||
| /// mid-restore had RESTORING written as its accepted baseline — and then deviated permanently by being | ||
| /// healthy. Measured on the production fleet: 636 alerts in 24 hours from 5 databases, every one reading | ||
| /// "Expected: RESTORING, Current: ONLINE", all of them consolidation restores that had been observed at | ||
| /// the wrong moment. The seed fix stops new ones; this repairs the rows already written, which the seed | ||
| /// cannot do because it is insert-if-absent by design.</para> | ||
| /// | ||
| /// <para>Runs beside the seed and prune every evaluation rather than as a migration rung: it is | ||
| /// self-healing (a baseline poisoned by any future path gets corrected on the next cycle), it needs no | ||
| /// schema change, and it is idempotent — once no auto-baseline names a transient state it deletes | ||
| /// nothing. Deleting rather than updating is deliberate: the correct steady state is not knowable here | ||
| /// (the database may still be restoring), and an absent baseline is exactly the "pending" condition the | ||
| /// seed already handles correctly on the next observation.</para> | ||
| /// | ||
| /// <para><c>is_user_override = false</c> is load-bearing. An operator who deliberately sets a database's | ||
| /// expected state to RESTORING — a permanently log-shipped target, say — means it, and their intent must | ||
| /// survive. Only baselines this code guessed are eligible. $1 server_id.</para> | ||
| /// </summary> | ||
| public const string RepairTransientDatabaseStateBaselineSql = @" | ||
| DELETE FROM config.database_state_expected | ||
| WHERE server_id = $1 | ||
| AND is_user_override = false | ||
| AND expected_state IN ('RESTORING', 'RECOVERING', 'SUSPECT', 'RECOVERY_PENDING', 'EMERGENCY')"; | ||
|
|
||
| /// <summary> | ||
| /// Tidies auto-baselines for databases no longer in the newest snapshot (dropped/renamed); user | ||
| /// overrides are kept. $1 server_id. | ||
|
|
@@ -784,6 +812,16 @@ public async Task<List<DatabaseStateInfo>> GetDatabaseStatesAsync( | |
| var items = new List<DatabaseStateInfo>(); | ||
| await using var connection = await _postgres.OpenConnectionAsync(cancellationToken); | ||
|
|
||
| /* BEFORE the seed, deliberately (#2189): dropping a transient-state baseline first lets the seed | ||
| re-learn the real steady state in the SAME cycle. Repairing afterwards would leave the database | ||
| with no baseline until the next pass, and the seed is insert-if-absent so it can never correct a | ||
| row itself. */ | ||
| using (var repair = new NpgsqlCommand(RepairTransientDatabaseStateBaselineSql, connection)) | ||
| { | ||
| repair.Parameters.AddWithValue(serverId); | ||
| await repair.ExecuteNonQueryAsync(cancellationToken); | ||
| } | ||
|
|
||
| using (var seed = new NpgsqlCommand(SeedDatabaseStateExpectedSql, connection)) | ||
| { | ||
| seed.Parameters.AddWithValue(serverId); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This entry references
[#2189]but no[#2189]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/2189link definition was added to the reference block at the bottom of the file (it currently jumps from[#2138]to[#2190]around line 2677-2678). The link will render broken/unresolved in the rendered changelog.