Skip to content

Index Analysis connects to the database it analyses on Azure - #2409

Merged
erikdarlingdata merged 3 commits into
devfrom
fix/2407-azure-utility-db-cannot-cross-databases
Aug 21, 2026
Merged

Index Analysis connects to the database it analyses on Azure#2409
erikdarlingdata merged 3 commits into
devfrom
fix/2407-azure-utility-db-cannot-cross-databases

Conversation

@erikdarlingdata

Copy link
Copy Markdown
Owner

@ZedZipDev on #2407, with 50 Azure SQL databases: set Utility DB to db1, Index Analysis for db1 works, Index Analysis for db2 reports no valid database.

His diagnosis is right. 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 at all. The proc runs inside whichever database the connection opened; @database_name asks it to read a different one; Azure refuses. The message he saw is the proc's own, and it reads like the database is missing rather than unreachable, which is the part that cost him the time.

On Azure the connection now targets the database being analysed rather than the utility database. The proc must be installed in each database regardless — which is exactly what he found by experiment — so pointing at the target is the only shape that can work. On every other engine the Utility DB behaviour is unchanged.

Two supporting changes:

All Databases is refused on Azure with an explanation, rather than half-filling the grid from whichever database the connection happened to open. Enumerating every database is the same cross-database read, so it cannot work either.

The not-installed branch names the database it checked. "sp_IndexCleanup is not installed" against a server with fifty databases is not actionable without saying which one — and on Azure the answer is now per-database rather than per-server.

The Utility DB tooltip says it is ignored on Azure and why, which is what he suggested.

Azure is detected from the collected engine_edition (5), the same signal MainWindow.AlertEngine and the Query Store backfill already use — no extra round trip.

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 a different one.

@ZedZipDev found the shape by experiment: with Utility DB set to db1,
analysing db1 works and analysing db2 reports no valid database. That is
the proc's own message and it reads like the database is missing rather
than unreachable, which is the part that cost him the time.

On Azure the connection now targets the database being ANALYSED rather
than the utility database. The proc has to be installed in each database
regardless, so pointing at the target is the only shape that can work, and
it is what he was already doing manually.

Two supporting changes. All Databases is refused on Azure with an
explanation rather than half-filling the grid from whichever database the
connection happened to open -- enumerating every database is the same
cross-database read. And the not-installed branch names the database it
checked, because "not installed" against a server with fifty databases is
not actionable without saying which one.

The Utility DB tooltip says it is ignored on Azure and why, which is what
he suggested.

Fixes #2407

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment on lines +976 to +979
var properties = _dataService == null
? null
: await _dataService.GetLatestServerPropertiesAsync(GetSelectedServerId());
var isAzureSqlDb = properties?.EngineEdition == 5;

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 +992 to +994
var utilityConnectionString = isAzureSqlDb && !string.IsNullOrWhiteSpace(databaseNameEarly)
? _credentialResolver.GetConnectionStringForDatabase(server, databaseNameEarly!)
: _credentialResolver.GetUtilityConnectionString(server);

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.

Comment on lines 253 to 330
internal enum EndpointToggleOrigin
{
/// <summary>darling.json, because the worker has not published yet (still bootstrapping, or it never
/// reached the store). PROVISIONAL: the control plane can contradict it within one poll interval.</summary>
File,

/// <summary>The store's <c>config.config_service</c> row, published by the worker. Authoritative.</summary>
ControlPlane,
}

/// <summary>The effective (enabled, port) a supervisor acts on, WITH its provenance (#2389).
/// <see cref="EnabledOverridden"/> / <see cref="PortOverridden"/> are true only when the control plane
/// supplied a value that DIFFERS from darling.json's -- the reportable disagreement.</summary>
internal readonly record struct EndpointToggle(
bool Enabled, int Port, EndpointToggleOrigin Origin, bool EnabledOverridden, bool PortOverridden);

/// <summary>
/// PURE: the store row wins whenever the worker has published one -- byte-for-byte the old
/// <c>published?.Enabled ?? config.Mcp.Enabled</c> pair -- but it also reports WHERE the values came from
/// and whether they contradict the file, which a null-coalesce structurally cannot.
/// </summary>
internal static EndpointToggle ResolveEndpointToggle((bool Enabled, int Port)? published, bool fileEnabled, int filePort)
=> published is null
? new EndpointToggle(fileEnabled, filePort, EndpointToggleOrigin.File, false, false)
: new EndpointToggle(
published.Value.Enabled,
published.Value.Port,
EndpointToggleOrigin.ControlPlane,
EnabledOverridden: published.Value.Enabled != fileEnabled,
PortOverridden: published.Value.Port != filePort);

/// <summary>
/// PURE: the provenance clause the start line carries, so "Starting ... on http://..." says on whose
/// authority it is starting -- and admits when it is running on file values the control plane has not
/// weighed in on yet, which is the line the operator greps and stops reading.
/// </summary>
internal static string DescribeToggleOrigin(EndpointToggle toggle)
=> toggle.Origin == EndpointToggleOrigin.ControlPlane
? "the control plane (config.config_service)"
: "darling.json (PROVISIONAL - the control plane has not published yet and may stop or rebind this server)";

/// <summary>
/// PURE: the one-line report of a control-plane override, or null when the two planes agree (or nothing is
/// published yet, in which case there is nothing to disagree with). <paramref name="section"/> is the
/// darling.json object name, the config_service column prefix, and the CLI verb suffix at once ("mcp" -&gt;
/// mcp.enabled / config_service.mcp_enabled / --enable-mcp), which is what keeps the MCP and web wordings
/// from drifting apart. The file values are passed in rather than re-derived so the message quotes what the
/// caller actually loaded.
/// </summary>
internal static string? DescribeToggleOverride(
EndpointToggle toggle, string section, string surface, bool fileEnabled, int filePort)
{
if (toggle.Origin != EndpointToggleOrigin.ControlPlane || (!toggle.EnabledOverridden && !toggle.PortOverridden))
{
return null;
}

var fields = new List<string>(2);
if (toggle.EnabledOverridden)
{
fields.Add(
$"enabled is {(fileEnabled ? "true" : "false")} in darling.json ({section}.enabled) but "
+ $"{(toggle.Enabled ? "true" : "false")} in config.config_service.{section}_enabled");
}

if (toggle.PortOverridden)
{
fields.Add($"port is {filePort} in darling.json ({section}.port) but {toggle.Port} in config.config_service.{section}_port");
}

return $"{surface} configuration disagrees across the two planes and the CONTROL PLANE WINS: "
+ string.Join("; ", fields)
+ $". After the first run darling.json's {section}.enabled/{section}.port are only the SEED -- change them with "
+ $"--enable-{section}/--disable-{section} or the Viewer's Settings, or the file values will keep being ignored. "
+ $"The {section}.network block is the OPPOSITE: file-only, restart-only, no store equivalent -- so an exposure "
+ "block in darling.json is live even while the control plane keeps this endpoint off.";
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Test-coverage gap: ResolveEndpointToggle, DescribeToggleOrigin, and DescribeToggleOverride are all marked PURE in their doc comments and have no unit tests — I couldn't find any references to them under Darling/Darling.Tests. This codebase otherwise tests exactly this shape of pure helper directly (e.g. DarlingEndpointToggleCliTests.cs for ClassifyFirewallPlan/ClassifyAllowFrom, CredentialProfileTests.cs for the Lite Resolve*ConnectionString siblings). Worth pinning at least: ResolveEndpointToggle with published = null vs. a value that agrees/disagrees with the file (checking EnabledOverridden/PortOverridden independently), and DescribeToggleOverride returning null when they agree vs. the exact wording when only one of the two fields disagrees.

@claude

claude Bot commented Aug 21, 2026

Copy link
Copy Markdown

Review summary

This diff bundles three distinct changes: the Azure Index Analysis connection-targeting fix (#2407, Lite), an MCP get_alert_settings field-parity expansion (#2394, Lite), and endpoint-toggle provenance logging for the Darling MCP/Web host supervisors (#2389, Darling-only — no Lite parity gap, since Lite has no control-plane/file duality to reconcile).

Correctness (left as inline comments):

  • Lite/Controls/FinOpsTab.xaml.cs ([BUG] DB Utility #2407 fix): the Azure detection reads _dataService.GetLatestServerPropertiesAsync(...), backed by an on-load-only collector that runs ~29th of 35, sequentially, after the FinOps tab is already clickable. A user who clicks "Run Analysis" before that collection completes gets isAzureSqlDb = false and silently falls back to the pre-fix Utility-DB cross-database path — reproducing the original bug. _serverManager.GetConnectionStatus(server.Id).SqlEngineEdition, already available on this class, is populated synchronously and would close that window.
  • Same method: leaving the database field blank on Azure (a legal, unvalidated state) still consults server.UtilityDatabase via GetUtilityConnectionString, contradicting the updated tooltip's claim that Utility DB is "ignored on Azure SQL Database."

Minor: the new pure helpers in DarlingHostBinding.cs (ResolveEndpointToggle, DescribeToggleOrigin, DescribeToggleOverride) and the new ResolveConnectionStringForDatabase/GetConnectionStringForDatabase pair in Lite have no unit tests, despite the codebase's established convention of testing exactly this kind of extracted pure function.

Good things noted, no action needed:

  • ResolveConnectionStringForDatabase builds the connection string via SqlConnectionStringBuilder, and sp_IndexCleanup's @database_name/@get_all_databases are passed as real ADO.NET parameters — no injection risk.
  • The get_alert_settings expansion in McpAlertTools.cs matches Darling's field names, nesting, and group order field-for-field (including the deliberate self_alerts omission), so Lite/Darling MCP parity is actually improved here, not drifted.
  • T-SQL style guidance doesn't apply — no .sql files changed in this PR.

@erikdarlingdata

Copy link
Copy Markdown
Owner Author

Heads-up on a lane collision, not a review note — this PR is carrying two files that belong to #2394, and they are mine rather than yours.

bf0eef5c includes Lite/Mcp/McpAlertTools.cs (+101/-1) and Lite/Mcp/McpInstructions.cs (+1/-1). That diff is the read-only alert-settings parity work for #2394 — thirteen new groups on get_alert_settings plus the instructions-table row that describes them. It was sitting uncommitted in the shared working tree while I was mid-build, and a blanket git add swept it into this commit. Nothing of yours was lost or altered; the two files are purely additive here, which is why the build stayed green and nothing flagged it.

It is now filed properly as #2410 off dev, with the test extension (Lite.Tests/McpAlertSettingsKeyTests.cs) that this commit does not have — so as it stands the code would land here without the pin that holds it.

Your call how to handle it. Two options that both work:

  1. Drop the two files from this branch (git checkout dev -- Lite/Mcp/McpAlertTools.cs Lite/Mcp/McpInstructions.cs, commit). Lite's MCP reports every alert group Darling's does #2410 then lands them with its tests, and this PR goes back to being about Azure index analysis only. This is the one I would pick — the files have nothing to do with the change this PR describes, and a reviewer reading the title will not be looking for them.
  2. Leave them and merge this first. Lite's MCP reports every alert group Darling's does #2410's diff narrows to the test file on its own once this lands; I will rebase and it stays mergeable either way.

The only outcome to avoid is both merging with the files in each, which would conflict on McpAlertTools.cs. Say which you prefer and I will work around it — I will not push to this branch.

Separately, and only because you may want to check the same way: Lite/Controls/FinOpsTab.xaml.cs, Lite/Models/ServerConnection.cs, Lite/Services/CredentialResolver.cs and Lite/Windows/AddServerDialog.xaml are also in this commit. Some of those are plausibly yours, but the same sweep that caught my two would have caught anything else uncommitted at that moment, so they are worth a look before merge.

@erikdarlingdata

Copy link
Copy Markdown
Owner Author

Heads up — bf0eef5c on this branch carries three files that are not part of this change:

  • Darling/PerformanceMonitor.Darling.Service/Hosting/DarlingHostBinding.cs (+97)
  • Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpHostService.cs (+52/-13)
  • Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingWebHostService.cs (+50/-11)

Those are #2389 (the MCP enabled/network authority split), and they were uncommitted work sitting in the shared checkout when this branch was created — a commit -a picked them up. Nothing here is wrong with them, they just belong to a different lane and this PR's body doesn't describe them, so a reviewer reading the diff gets three unexplained Darling service files under an Azure Index Analysis story. They'd also land on dev without the tests that pin them, which are in #2411.

Your call how to handle it, but the cheap fix is to drop them from this branch:

git restore --source=origin/dev -- \
  Darling/PerformanceMonitor.Darling.Service/Hosting/DarlingHostBinding.cs \
  Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpHostService.cs \
  Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingWebHostService.cs
git commit --amend --no-edit && git push --force-with-lease

(An amend is safe here — no review has anchored on this diff yet. A plain follow-up commit reverting the three works too if you'd rather not rewrite.)

#2411 has the same three files plus their tests, so if this merges first that PR will conflict on them; if you drop them here, #2411 stays clean either way. I'm not touching this branch — flagging it so whichever of us merges second doesn't get surprised.

Unrelated to this, Lite.Tests/McpAlertSettingsKeyTests.cs is currently modified in the shared checkout's working tree and uncommitted; I left it alone in case it's yours in flight.

erikdarlingdata and others added 2 commits August 21, 2026 13:17
This branch picked up in-progress work from three other lines of work
because the commit that created it staged everything in a shared checkout
rather than the paths it had actually edited: the MCP plane-authority
resolver (#2389), Lite's MCP alert-settings parity (#2394), and Lite's
analysis re-notify cooldown box (#2393, since merged).

None of it belonged here. All eight files are restored to their dev state,
leaving this branch as the four files #2407 actually touches.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@erikdarlingdata

Copy link
Copy Markdown
Owner Author

Corrected: this branch had picked up in-progress work from three other lines — the MCP plane-authority resolver (#2389/#2411), Lite's MCP alert-settings parity (#2394), and Lite's analysis re-notify cooldown box (#2393, since merged).

Entirely my fault, and worth naming the cause rather than just the effect: the commit that created this branch ran git add -A in a checkout that several concurrent agents were editing, so it staged their working-tree changes along with its own. git status looked like my work because I only glanced at the paths I expected.

All eight files are restored to their dev state and dev is merged in. This PR is now the four files #2407 actually touches:

Lite/Controls/FinOpsTab.xaml.cs
Lite/Models/ServerConnection.cs
Lite/Services/CredentialResolver.cs
Lite/Windows/AddServerDialog.xaml

#2411 and the #2394 branch are unaffected — they hold the authoritative copies of what was swept up here, and neither needs to wait on this. Thanks to the #2389 agent for catching it; it would otherwise have merged three half-finished changes under an unrelated title.

Comment on lines +977 to +979
? null
: await _dataService.GetLatestServerPropertiesAsync(GetSelectedServerId());
var isAzureSqlDb = properties?.EngineEdition == 5;

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.

Comment on lines +981 to +990
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;
}

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.

@claude

claude Bot commented Aug 21, 2026

Copy link
Copy Markdown

Reviewed the diff (net change: Lite/Controls/FinOpsTab.xaml.cs, Lite/Models/ServerConnection.cs, Lite/Services/CredentialResolver.cs, Lite/Windows/AddServerDialog.xaml).

Summary: the fix itself is sound — routing the connection at the target database for Azure, refusing "All Databases" there, and naming the database in the not-installed message all directly address the #2407 report. ResolveConnectionStringForDatabase/GetConnectionStringForDatabase use SqlConnectionStringBuilder and parameterized @database_name, so no connection-string or SQL injection concerns. No .sql changes, so the T-SQL style rules don't apply here.

Parity: checked Darling's Index Analysis (FinOpsTab.IndexAnalysis.cs in the Viewer) — it reads a previously-collected snapshot and runs the analyzer locally, with no live proc invocation or Utility DB concept (UtilityDatabase on ViewerServerEntry is an unused/vestigial field). No cross-database execution path exists there, so no parity gap from this PR.

Findings posted inline:

  1. Azure detection uses the last stored server_properties snapshot (GetLatestServerPropertiesAsync) rather than the live _serverManager.GetConnectionStatus(...).SqlEngineEdition that MainWindow.AlertEngine and RemoteCollectorService already use for this exact check. Since server_properties is an on-load-only collector, a freshly added server (or one where that collector hasn't completed/is disabled) will silently fall through to the pre-fix behavior — reproducing the original bug for the population most likely to hit it.
  2. Minor: the new "All Databases refused on Azure" early-return doesn't clear the result grids/banners the way the "not installed" branch does, so stale results from a prior run can remain visible underneath the new error text.

@erikdarlingdata
erikdarlingdata merged commit c18a918 into dev Aug 21, 2026
6 checks passed
@erikdarlingdata
erikdarlingdata deleted the fix/2407-azure-utility-db-cannot-cross-databases branch August 21, 2026 13:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant