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
117 changes: 117 additions & 0 deletions Dashboard.Tests/RecommendationDeduperTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,24 @@ public void FromEngineSeverity_BoundaryCases(double severity, CanonicalSeverity
Assert.Equal(expected, RecommendationDeduper.FromEngineSeverity(severity));
}

// ---- RCSI card severity scales with the contention it would relieve ----

[Theory]
[InlineData(0, 0, CanonicalSeverity.Info)] // gated in (rw>=50) but minimal magnitude
[InlineData(9, 0, CanonicalSeverity.Info)] // just under the Warning blocking threshold
[InlineData(10, 0, CanonicalSeverity.Warning)] // notable reader/writer blocking
[InlineData(0, 1, CanonicalSeverity.Warning)] // a deadlock escalates off Info
[InlineData(99, 9, CanonicalSeverity.Warning)] // just under Critical on both axes
[InlineData(100, 0, CanonicalSeverity.Critical)] // extreme blocking
[InlineData(0, 10, CanonicalSeverity.Critical)] // many deadlocks
public void RcsiSeverityBand_ScalesWithContention(int blocking, int deadlocks, CanonicalSeverity expected)
{
// rwPct doesn't affect the band (the >=50% reader/writer gate already decided the card
// exists); the band reflects magnitude. 80 here is just a representative gated value.
var band = RecommendationsReader.RcsiSeverityBand(new RcsiInactionFigures(blocking, deadlocks, 80));
Assert.Equal(expected, band);
}

// ---- canonical severity: legacy text ---------------------------------

[Theory]
Expand Down Expand Up @@ -523,6 +541,105 @@ public void MapEngineFindings_NonDbConfig_ReturnsSingleItemUnchanged()
Assert.Same(action, item.Remediation); // same single action, NOT sliced
}

// ---- per-db RCSI fan-out of DB_CONFIG findings (destructive, consent-gated) ----

[Fact]
public void MapEngineFindings_DbConfig_RcsiTargets_FanToPerDbRcsiCardsWithRcsiAction()
{
// A DB_CONFIG finding whose action carries TWO per-db RCSI targets fans to TWO RCSI
// cards — each a distinct FactKey="RCSI" action (dispatches to RcsiHandler + the two-
// sided consent gate) carrying THAT db's real inaction figures.
var action = new RemediationAction(
"DB_CONFIG", "set", Array.Empty<ForcePlanTarget>(),
DbConfigTargets: Array.Empty<DbConfigTarget>(),
RcsiTargets: new[]
{
new RcsiTarget("Sales", new RcsiInactionFigures(40, 2, 85)),
new RcsiTarget("Orders", new RcsiInactionFigures(12, 0, 30))
});

var finding = new AnalysisFinding
{
ServerId = 1,
ServerName = "S",
DatabaseName = null, // DB_CONFIG is SERVER-scoped
Severity = 0.3,
Category = "database_config",
RootFactKey = "DB_CONFIG",
StoryPathHash = "h",
StoryPath = "p",
Remediation = action
};

var items = RecommendationsReader.MapEngineFindings(finding);

Assert.Equal(2, items.Count);
Assert.All(items, i =>
{
Assert.Equal(RecommendationSource.Engine, i.Source);
Assert.Equal(RecommendationSetting.Rcsi, i.Setting);
Assert.NotNull(i.Remediation);
Assert.Equal("RCSI", i.Remediation!.FactKey); // dispatches to RcsiHandler
Assert.NotNull(i.Remediation.RcsiFigures); // real figures for the consent dialog
Assert.Contains("READ_COMMITTED_SNAPSHOT ON", i.CopyPasteSql);
Assert.Contains(i.Database!, i.Title); // Title names the db
// The reconstructed action's single target is THIS db with the RCSI setting.
var target = Assert.Single(i.Remediation.DbConfigTargets!);
Assert.Equal(i.Database, target.Database);
Assert.Equal(DbConfigSetting.ReadCommittedSnapshotOn, target.Setting);
Assert.Equal("h", i.StoryPathHash);
});

var sales = Assert.Single(items, i => i.Database == "Sales");
Assert.Equal(40, sales.Remediation!.RcsiFigures!.BlockingEvents);
Assert.Equal(2, sales.Remediation.RcsiFigures.Deadlocks);
Assert.Equal(85, sales.Remediation.RcsiFigures.ReaderWriterPct);

var orders = Assert.Single(items, i => i.Database == "Orders");
Assert.Equal(12, orders.Remediation!.RcsiFigures!.BlockingEvents);
Assert.Equal(0, orders.Remediation.RcsiFigures.Deadlocks);
Assert.Equal(30, orders.Remediation.RcsiFigures.ReaderWriterPct);
}

[Fact]
public void MapEngineFindings_DbConfig_SafeAndRcsiTargets_ProduceBothSetsOfCards()
{
// One DB_CONFIG finding carrying BOTH a safe AUTO_SHRINK target and an RCSI target must
// produce a safe-setting card AND an RCSI card (the two fan-outs are independent).
var action = new RemediationAction(
"DB_CONFIG", "set", Array.Empty<ForcePlanTarget>(),
DbConfigTargets: new[] { new DbConfigTarget("Sales", DbConfigSetting.AutoShrinkOff, "ON") },
RcsiTargets: new[] { new RcsiTarget("Orders", new RcsiInactionFigures(12, 3, 80)) });

var finding = new AnalysisFinding
{
ServerId = 1,
ServerName = "S",
DatabaseName = null,
Severity = 0.3,
Category = "database_config",
RootFactKey = "DB_CONFIG",
StoryPathHash = "h",
StoryPath = "p",
Remediation = action
};

var items = RecommendationsReader.MapEngineFindings(finding);

Assert.Equal(2, items.Count);
// The safe AUTO_SHRINK card: DB_CONFIG action, AutoShrink setting.
var safe = Assert.Single(items, i => i.Setting == RecommendationSetting.AutoShrink);
Assert.Equal("Sales", safe.Database);
Assert.Equal("DB_CONFIG", safe.Remediation!.FactKey);
Assert.Contains("AUTO_SHRINK OFF", safe.CopyPasteSql);
// The destructive RCSI card: distinct FactKey="RCSI" action with figures.
var rcsi = Assert.Single(items, i => i.Setting == RecommendationSetting.Rcsi);
Assert.Equal("Orders", rcsi.Database);
Assert.Equal("RCSI", rcsi.Remediation!.FactKey);
Assert.Equal(12, rcsi.Remediation.RcsiFigures!.BlockingEvents);
Assert.Contains("READ_COMMITTED_SNAPSHOT ON", rcsi.CopyPasteSql);
}

[Fact]
public void EngineDbConfigFanOut_DeDupesWithLegacyPerDbRow_EngineWinsWithApply()
{
Expand Down
24 changes: 24 additions & 0 deletions Dashboard.Tests/RecommendationsViewModelTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,30 @@ public void AutogrowthFix_ShowsCopyFixAndApply_NotIncident()
Assert.True(card.ShowApply); // autogrowth IS Apply-able (FileAutogrowthHandler)
}

[Fact]
public void RcsiCard_ShowsApplyAndCopyFix_NotIncident()
{
// A per-db RCSI recommendation: Setting=Rcsi (config-fix, NOT a time-bound incident) with
// a distinct FactKey="RCSI" action. It must offer Apply (-> RcsiHandler + the two-sided
// consent gate) and Copy fix (the ALTER), and never the incident affordances.
var card = Card(Item(
CanonicalSeverity.Warning,
setting: RecommendationSetting.Rcsi,
remediation: new RemediationAction(
"RCSI", "set", Array.Empty<ForcePlanTarget>(),
new[] { new DbConfigTarget("Sales", DbConfigSetting.ReadCommittedSnapshotOn, "OFF") },
RcsiFigures: new RcsiInactionFigures(40, 2, 85)),
sql: "ALTER DATABASE [Sales] SET READ_COMMITTED_SNAPSHOT ON;",
db: "Sales"));

Assert.False(card.IsIncident);
Assert.True(card.IsConfigFix);
Assert.True(card.ShowApply);
Assert.True(card.ShowCopyFix);
Assert.False(card.ShowOpenInActiveQueries);
Assert.False(card.ShowAskAi);
}

// ---- Mute visibility (Source == Engine) -------------------------------

[Fact]
Expand Down
33 changes: 33 additions & 0 deletions Dashboard.Tests/RemediationApplyServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -644,6 +644,39 @@ await service.ApplyAsync(persistedAction, Server, previewSql: "preview", "DOM\\o
Assert.DoesNotContain(captured.Risks.RisksOfNotChanging, r => r.Text.Contains("Little or no reader/writer blocking"));
}

[Fact]
public void RcsiTargets_OnDbConfigAction_SurviveAlertContextRoundTrip_WithFigures()
{
// The per-db RCSI targets carried on a DB_CONFIG action (for the read-time card fan-out)
// must survive the AlertContext serialize -> deserialize round-trip with their figures
// intact — otherwise the Recommendations reader fans no RCSI cards after persistence.
var action = new RemediationAction(
"DB_CONFIG", "set", Array.Empty<ForcePlanTarget>(),
DbConfigTargets: new[] { new DbConfigTarget("Sales", DbConfigSetting.AutoShrinkOff, "ON") },
RcsiTargets: new[]
{
new RcsiTarget("Sales", new RcsiInactionFigures(40, 2, 85)),
new RcsiTarget("Orders", new RcsiInactionFigures(12, 0, null))
});

var ctx = new AlertContext();
ctx.Details.Add(new AlertDetailItem { Heading = "DB config", IsCodeBlock = true, Remediation = action });
Assert.True(AlertContextSerializer.TryDeserialize(AlertContextSerializer.Serialize(ctx), out var round));
var persisted = round.Details[0].Remediation!;

// Safe target preserved...
Assert.Single(persisted.DbConfigTargets!);
// ...and the two RCSI targets with their figures.
Assert.Equal(2, persisted.RcsiTargets!.Count);
var sales = Assert.Single(persisted.RcsiTargets, t => t.Database == "Sales");
Assert.Equal(40, sales.Figures.BlockingEvents);
Assert.Equal(2, sales.Figures.Deadlocks);
Assert.Equal(85, sales.Figures.ReaderWriterPct);
var orders = Assert.Single(persisted.RcsiTargets, t => t.Database == "Orders");
Assert.Equal(12, orders.Figures.BlockingEvents);
Assert.Null(orders.Figures.ReaderWriterPct); // nullable pct round-trips as null
}

[Fact]
public async Task Apply_Destructive_NoFiguresNoFinding_ShowsWeakCaseBaseline()
{
Expand Down
73 changes: 66 additions & 7 deletions Dashboard.Tests/RemediationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1005,13 +1005,23 @@ public void BuildRcsiAction_ReturnsNull_WhenEnrichmentAbsent()
}

[Fact]
public void BuildAction_Unchanged_NeverEmitsRcsi_Phase2RegressionGuard()
{
// The always-safe BuildAction must STILL never emit RCSI, even on the enriched
// RCSI-off finding — the destructive arm rides BuildRcsiAction only.
Assert.Null(FactRemediation.BuildAction(RcsiOffFinding()));

// And a mixed finding (RCSI off + a safe setting) yields only the safe target.
public void BuildAction_NeverPutsRcsiInSafeTargets_ButCarriesRcsiTargets()
{
// The always-safe BuildAction must STILL never put RCSI in the EXECUTED DbConfigTargets
// (DbConfigHandler runs those). RCSI is now CARRIED on the action's RcsiTargets purely so
// the Recommendations reader can fan per-db RCSI cards — never executed from here. On an
// enriched, CONTENDED RCSI-off finding the action exists (so the reader can fan), has NO
// safe DbConfigTargets, and carries exactly the one RCSI target.
var rcsiOnly = FactRemediation.BuildAction(RcsiOffFinding());
Assert.NotNull(rcsiOnly);
Assert.Equal("DB_CONFIG", rcsiOnly!.FactKey);
Assert.True(rcsiOnly.DbConfigTargets is null || rcsiOnly.DbConfigTargets.Count == 0);
var carried = Assert.Single(rcsiOnly.RcsiTargets!);
Assert.Equal("Foo", carried.Database);
Assert.Equal(12, carried.Figures.BlockingEvents);

// And a mixed finding (RCSI off + a safe setting) yields the safe target in
// DbConfigTargets (never RCSI) AND carries the RCSI target separately.
var mixed = DbConfigFinding(new List<object>
{
new { database = "Foo", rcsi = false, query_store = true,
Expand All @@ -1022,7 +1032,56 @@ public void BuildAction_Unchanged_NeverEmitsRcsi_Phase2RegressionGuard()
var safe = FactRemediation.BuildAction(mixed);
Assert.NotNull(safe);
Assert.Equal("DB_CONFIG", safe!.FactKey);
Assert.NotEmpty(safe.DbConfigTargets!);
Assert.All(safe.DbConfigTargets!, t => Assert.NotEqual(DbConfigSetting.ReadCommittedSnapshotOn, t.Setting));
Assert.Single(safe.RcsiTargets!); // RCSI carried for the card fan-out, not executed
}

[Fact]
public void CollectRcsiTargets_OnlyReaderWriterContention_WriterWriterAndUnknownExcluded()
{
// RCSI only relieves reader-vs-writer blocking. The gate is the reader/writer SHARE
// (>= FactRiskDisclosure.ReaderWriterMeaningfulPct), NOT raw blocking/deadlock counts:
// - reader/writer-dominant -> recommended
// - writer/writer-dominant -> NOT recommended, even with HEAVY blocking (RCSI does
// nothing for X/IX/U vs X/IX/U contention)
// - no blocked-process detail -> NOT recommended (pct null — can't confirm RCSI helps)
var finding = DbConfigFinding(new List<object>
{
new { database = "ReaderWriter", rcsi = false, query_store = true,
auto_shrink = false, auto_close = false, page_verify = "CHECKSUM",
issues = new[] { "RCSI OFF" },
rcsi_blocking_events = 40, rcsi_deadlocks = 2, rcsi_reader_writer_pct = 85 },
new { database = "WriterWriter", rcsi = false, query_store = true,
auto_shrink = false, auto_close = false, page_verify = "CHECKSUM",
issues = new[] { "RCSI OFF" },
// Heavy blocking + deadlocks, but almost all writer/writer — RCSI won't relieve it.
rcsi_blocking_events = 500, rcsi_deadlocks = 12, rcsi_reader_writer_pct = 15 },
new { database = "Unknown", rcsi = false, query_store = true,
auto_shrink = false, auto_close = false, page_verify = "CHECKSUM",
issues = new[] { "RCSI OFF" },
rcsi_blocking_events = 30, rcsi_deadlocks = 0, rcsi_reader_writer_pct = (int?)null },
new { database = "AtThreshold", rcsi = false, query_store = true,
auto_shrink = false, auto_close = false, page_verify = "CHECKSUM",
issues = new[] { "RCSI OFF" },
rcsi_blocking_events = 10, rcsi_deadlocks = 0, rcsi_reader_writer_pct = 50 }
});

var collected = FactRemediation.CollectRcsiTargets(finding);

Assert.Equal(2, collected.Count);
Assert.DoesNotContain(collected, t => t.Database == "WriterWriter"); // heavy blocking, but writer/writer
Assert.DoesNotContain(collected, t => t.Database == "Unknown"); // no reader/writer detail captured
var rw = Assert.Single(collected, t => t.Database == "ReaderWriter");
Assert.Equal(40, rw.Figures.BlockingEvents);
Assert.Equal(85, rw.Figures.ReaderWriterPct);
Assert.Single(collected, t => t.Database == "AtThreshold"); // pct == threshold qualifies

// BuildAction carries exactly the two reader/writer targets (no safe DbConfigTargets here).
var action = FactRemediation.BuildAction(finding);
Assert.NotNull(action);
Assert.Equal(2, action!.RcsiTargets!.Count);
Assert.True(action.DbConfigTargets is null || action.DbConfigTargets.Count == 0);
}

// ── FactRiskDisclosure: two-sided, honest-both-directions, golden prose ──────
Expand Down
Loading
Loading