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
46 changes: 43 additions & 3 deletions Lite/Controls/FinOpsTab.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -960,11 +960,51 @@ private async void RunIndexAnalysis_Click(object sender, RoutedEventArgs e)

try
{
var utilityConnectionString = _credentialResolver.GetUtilityConnectionString(server);
var databaseNameEarly = IndexAnalysisDatabaseInput.Text?.Trim();
var allDatabasesEarly = IndexAnalysisAllDatabases.IsChecked == true;

/* #2407: Azure SQL Database has no cross-database execution, so the Utility DB idea — install
sp_IndexCleanup once and point it at any database on the server — cannot work there. The proc
runs INSIDE whichever database the connection opened, and @database_name asks it to read
another one, which Azure refuses. Reported as "set Utility DB to db1, analysing db1 works,
analysing db2 says no valid database" — the proc's own message, which reads like the database
is missing rather than unreachable.

So on Azure the connection targets the database being ANALYSED, not the utility database: the
proc has to be installed in each database anyway (which is what the reporter found by
experiment), and pointing at the target is the only shape that can work. */
var properties = _dataService == null
? null
: await _dataService.GetLatestServerPropertiesAsync(GetSelectedServerId());
var isAzureSqlDb = properties?.EngineEdition == 5;
Comment on lines +976 to +979

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Azure detection can silently fail open, reproducing the exact bug this PR fixes.

isAzureSqlDb is derived from _dataService.GetLatestServerPropertiesAsync(...), which reads the server_properties table. That collector is registered with FrequencyMinutes = 0 ("on-load only", Lite/Services/ScheduleManager.cs) and runs ~29th of ~35 collectors, strictly sequentially, inside RunAllCollectorsForServerAsync — which MainWindow.xaml.cs ConnectToServer kicks off after it has already added the server tab to ServerTabControl.Items and made it the selected item. RunIndexAnalysisButton has no gating on initial load completing (it's only disabled while an analysis is in flight), so a user can open the FinOps tab and click "Run Analysis" before server_properties has ever been collected for that server — e.g. right after AddServer(), which doesn't trigger any collection at all.

In that window properties is null, so isAzureSqlDb is false, and the code takes the pre-fix branch: _credentialResolver.GetUtilityConnectionString(server). On Azure SQL Database with a Utility DB configured, that's precisely the "Utility DB set to db1, analysing db2 reports no valid database" bug from #2407 — it just resurfaces on a timing window instead of unconditionally.

_serverManager.GetConnectionStatus(server.Id).SqlEngineEdition == 5 (already used the same way elsewhere, e.g. MainWindow.AlertEngine.cs:84/230, RemoteCollectorService.cs:634) is populated synchronously by the permission-free connectivity-check query and is available far earlier/more reliably — _serverManager is already a field on this class and is even null-checked at the top of this same method. That looks like the more robust signal to key this fix off of.

Comment on lines +977 to +979

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Azure detection here reads _dataService.GetLatestServerPropertiesAsync(serverId), i.e. the last stored server_properties snapshot in local DuckDB storage. That's a different, weaker signal than the one the PR description says it mirrors.

MainWindow.AlertEngine.cs (and RemoteCollectorService) detect Azure from _serverManager.GetConnectionStatus(server.Id).SqlEngineEdition == 5 — the live connection-status field populated by the lightweight connectivity check, independent of any collection cycle.

server_properties is an "on-load only" collector (Lite/Services/ScheduleManager.cs, FrequencyMinutes = 0). If it hasn't run yet for this server (freshly added server, in-flight/failed on-load collection, or the collector disabled in settings), properties is null here, isAzureSqlDb silently defaults to false, and the code falls through to GetUtilityConnectionString(server) — reproducing the exact cross-database failure from #2407 for the population most likely to hit it (a server just added and not yet fully collected).

Consider using _serverManager.GetConnectionStatus(server.Id).SqlEngineEdition == 5 instead, matching the existing pattern and avoiding the dependency on a prior successful collection cycle.


if (isAzureSqlDb && allDatabasesEarly)
{
/* Enumerating every database from one connection is the same cross-database read, so All
Databases cannot work on Azure either — and failing per-database would half-fill the grid
with whichever database the connection happened to open. */
IndexAnalysisStatusText.Text =
"Azure SQL Database cannot analyse across databases — clear \u201CAll Databases\u201D and name one, "
+ "with sp_IndexCleanup installed in it.";
return;
}
Comment on lines +981 to +990

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: this early-return path only sets IndexAnalysisStatusText.Text and returns — it doesn't clear IndexAnalysisNotInstalledMessage/IndexAnalysisNoDataMessage or the summary/detail grids the way the "not installed" branch below does. If a user already has analysis results on screen from a prior run, checks "All Databases" and re-runs on Azure, the old grid data stays visible underneath the new error message, which can read as if the error applies to stale-but-still-shown results rather than replacing them.


var utilityConnectionString = isAzureSqlDb && !string.IsNullOrWhiteSpace(databaseNameEarly)
? _credentialResolver.GetConnectionStringForDatabase(server, databaseNameEarly!)
: _credentialResolver.GetUtilityConnectionString(server);
Comment on lines +992 to +994

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When Azure is correctly detected but the database field is left blank and "All Databases" is unchecked (a reachable, unvalidated combination — nothing requires filling in a name or checking the box), this falls through to _credentialResolver.GetUtilityConnectionString(server), which still honors server.UtilityDatabase on Azure.

That contradicts the updated tooltip in AddServerDialog.xaml ("Ignored on Azure SQL Database … Index Analysis connects to the named database directly"): if a Utility DB is configured, the connection silently opens against that database rather than "the connection database" the tooltip implies, and the analysis runs against the wrong database with no indication to the user of which one was actually checked. Worth either forcing the plain connection string (ignoring UtilityDatabase) whenever isAzureSqlDb is true, or requiring a database name up front on Azure the same way "All Databases" is now refused.


var exists = await LocalDataService.CheckSpIndexCleanupExistsAsync(utilityConnectionString);
if (!exists)
{
/* On Azure the proc must live in the target database, so name it — "not installed" against a
server with 50 databases is not actionable without saying which one was checked. */
if (isAzureSqlDb && !string.IsNullOrWhiteSpace(databaseNameEarly))
{
IndexAnalysisStatusText.Text =
$"sp_IndexCleanup is not installed in [{databaseNameEarly}]. Azure SQL Database cannot run it "
+ "from another database, so it must be installed in each database you analyse.";
}

IndexAnalysisNotInstalledMessage.Visibility = Visibility.Visible;
IndexAnalysisNoDataMessage.Visibility = Visibility.Collapsed;
_indexSummaryFilterMgr!.UpdateData(new List<IndexCleanupSummaryRow>());
Expand All @@ -977,8 +1017,8 @@ private async void RunIndexAnalysis_Click(object sender, RoutedEventArgs e)
RunIndexAnalysisButton.IsEnabled = false;
IndexAnalysisStatusText.Text = "Running analysis...";

var databaseName = IndexAnalysisDatabaseInput.Text?.Trim();
var getAllDatabases = IndexAnalysisAllDatabases.IsChecked == true;
var databaseName = databaseNameEarly;
var getAllDatabases = allDatabasesEarly;

var (details, summaries) = await LocalDataService.RunIndexAnalysisAsync(
utilityConnectionString,
Expand Down
27 changes: 27 additions & 0 deletions Lite/Models/ServerConnection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,33 @@ public static string ResolveUtilityConnectionString(
return builder.ConnectionString;
}

/// <summary>
/// Like <see cref="ResolveConnectionString"/> but targets an explicitly named database (#2407).
///
/// <para>Separate from <see cref="ResolveUtilityConnectionString"/> because it answers a different
/// question. That one asks "where is the community proc installed" and is a per-server setting; this one
/// asks "which database must the connection be opened in for the read to be legal", which on Azure SQL
/// Database is always the database being read — it has no cross-database execution, so a proc taking a
/// @database_name parameter can only ever be handed its own.</para>
/// </summary>
public static string ResolveConnectionStringForDatabase(
ServerConnection server,
string databaseName,
CredentialService credentialService,
IProfileLookup? profileLookup)
{
var baseConnStr = ResolveConnectionString(server, credentialService, profileLookup);

if (string.IsNullOrWhiteSpace(databaseName))
return baseConnStr;

var builder = new SqlConnectionStringBuilder(baseConnStr)
{
InitialCatalog = databaseName
};
return builder.ConnectionString;
}

/// <summary>
/// Builds the connection string with the given credentials.
/// Used by tests for the server-self shape; production paths go through
Expand Down
8 changes: 8 additions & 0 deletions Lite/Services/CredentialResolver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,14 @@ public string GetConnectionString(ServerConnection server)
public string GetUtilityConnectionString(ServerConnection server)
=> ServerConnection.ResolveUtilityConnectionString(server, _credentialService, _profileLookup);

/// <summary>
/// Resolves a connection string targeting a NAMED database rather than the server's configured default
/// or utility database (#2407). Azure SQL Database has no cross-database execution, so a proc that reads
/// a database has to be run from inside it — the Utility DB indirection cannot apply there.
/// </summary>
public string GetConnectionStringForDatabase(ServerConnection server, string databaseName)
=> ServerConnection.ResolveConnectionStringForDatabase(server, databaseName, _credentialService, _profileLookup);

/// <summary>
/// Profile-aware stored-credential check (N-1).
/// </summary>
Expand Down
2 changes: 1 addition & 1 deletion Lite/Windows/AddServerDialog.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@
<StackPanel Orientation="Horizontal" Margin="0,6,0,0">
<TextBlock Text="Utility DB:" Foreground="{DynamicResource ForegroundBrush}" VerticalAlignment="Center" Width="80"/>
<TextBox x:Name="UtilityDatabaseBox" Width="250"
ToolTip="Database where community stored procedures (sp_IndexCleanup) are installed. Leave empty to use the connection database."/>
ToolTip="Database where community stored procedures (sp_IndexCleanup) are installed. Leave empty to use the connection database.&#10;&#10;Ignored on Azure SQL Database (#2407): it has no cross-database execution, so a procedure can only read the database it runs in. Install sp_IndexCleanup in each database you want to analyse; Index Analysis connects to the named database directly."/>
</StackPanel>
<CheckBox x:Name="ReadOnlyIntentCheckBox" Content="Read-_only intent (for AG listeners and readable replicas)"
Foreground="{DynamicResource ForegroundBrush}" Margin="0,6,0,0"
Expand Down
Loading