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
30 changes: 30 additions & 0 deletions changelog.d/SE-247.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
- **An availability group can be failed over from its context menu: *Fail Over…*.** Pick the replica to
fail over to, point DataTray at the saved connections for the instances involved, and it shows the exact
plan — every statement and the instance it runs on — before anything runs. Which plan you get follows the
group's cluster type:
- **WSFC**: one statement on the target. A plain `FAILOVER` when the target is synchronous and every
database is failover-ready; otherwise `FORCE_FAILOVER_ALLOW_DATA_LOSS`, with a red warning, how far
behind each database is, and what to do about the old primary afterwards.
- **Read-scale (`CLUSTER_TYPE = NONE`)**: the documented sequence across both instances — make both
replicas synchronous, wait until the target is `SYNCHRONIZED`, take the group offline on the primary,
promote the target, demote and resume the old primary, re-create the listener. It stops before taking
the group offline if the target never catches up.
- **Pacemaker (`CLUSTER_TYPE = EXTERNAL`)**: DataTray refuses, because the cluster manager owns the
primary role, and shows the `pcs`/`crm` commands with the target node filled in to run yourself.

A forced or read-scale failover asks you to type the group name. Each picked connection is checked to
really be the replica it was picked for, and when you confirm, the state is read again: if the plan
changed in the meantime (a target that fell behind turns a planned failover into a forced one), nothing
runs. Failing over is not reachable from the MCP server.
- **New availability groups can be created from the Availability Groups folder: *New Availability
Group…*.** The instance you opened it on becomes the primary; tick the saved connections that should hold
the secondaries. A pre-flight over all of them comes first, and while anything blocks — one instance
only, Always On switched off, different versions or collations, an edition without availability groups,
a login without `CONTROL SERVER`, endpoints on only some instances — it shows only why, and no form. After
that the form offers only what can work on those instances: WSFC only when every instance is a node of the
same Windows cluster, read-scale (`NONE`) never preselected because it is not high availability, a basic
group on Standard edition, and only the databases that can join (the others are listed with the reason).
DataTray creates certificate-authenticated endpoints and exchanges the public certificates between the
instances itself, without writing a file anywhere, or reuses the endpoints if every instance already has
one. The databases are seeded automatically. As with failing over, the whole plan is shown per instance
before anything runs, and it is checked again when you confirm.
3 changes: 2 additions & 1 deletion changelog.d/SE-284.added.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@
"behind by" column — the primary's last commit time minus the replica's, in seconds, next to the DMVs'
own log-send/redo queue sizes in kilobytes. It is the only node dialog in DataTray that polls (every 10
seconds): queue depth moves while you are looking at it, unlike everything else this dialog family shows.
Creating or reconfiguring a group, and failing one over, stay out of scope for now (SE-247).
Failing a group over is its own action on the group (*Fail Over…*), and creating one is *New
Availability Group…* on the folder.
3 changes: 3 additions & 0 deletions src/DataTray.App/ViewModels/ToolDialogViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -503,6 +503,9 @@ public Task<QueryResult> QueryAsync(string sql, CancellationToken ct) =>
// DatabasePicker fields do.
IReadOnlyList<ToolConnectionInfo> IToolUiContext.ListConnections() => ((IToolHost)this).ListConnections();

ToolConnection? IToolUiContext.OpenConnection(string connectionId, string? database) =>
((IToolHost)this).OpenConnection(connectionId, database);

Task<IReadOnlyList<string>> IToolUiContext.ListDatabasesAsync(string connectionId, CancellationToken ct) =>
((IToolHost)this).ListDatabasesAsync(connectionId, ct);

Expand Down
4 changes: 4 additions & 0 deletions src/DataTray.Sdk/Tools/ToolHostApi.cs
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,10 @@ public static class ToolHostApi
// default interface members — an older host simply returns empty and a view that reads
// it says what is missing — so this is a fold-in by the SE-253 test ("does it add
// types?"), not a v9.
// also in v8 (2026-09-30): IToolUiContext.OpenConnection(id, database), mirroring IToolHost's (SE-247).
// The availability group failover view reads the group's state from its primary before
// the run, and the primary is often not the connection the tool was launched on. A
// default interface member returning null — no new types — so a fold-in by the same test.
public const int Version = 8;

/// <summary>Oldest plugin ABI this host still loads. Every bump has been additive (v2 tool defaults, v3
Expand Down
5 changes: 5 additions & 0 deletions src/DataTray.Sdk/Ui/IToolUiContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,11 @@ public interface IToolUiContext
Task<IReadOnlyList<string>> ListDatabasesAsync(string connectionId, CancellationToken ct) =>
Task.FromResult<IReadOnlyList<string>>([]);

/// <summary>Open one of those connections, the same way <see cref="IToolHost.OpenConnection"/> does for the
/// running tool, so a view can show live data from a second instance before the run — the availability
/// group failover reads the group's state from its primary. Null default for an older host.</summary>
ToolConnection? OpenConnection(string connectionId, string? database = null) => null;

// ── Lifecycle-owning views (IToolDialogLifecycle) ─────────────────────────────────────────────────

/// <summary>The plugin's own localizer, so a custom view can translate its labels the same way the
Expand Down
142 changes: 142 additions & 0 deletions src/DataTray.Tools.MsSqlAdmin/AgStepRunner.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
using System.Text;
using System.Text.RegularExpressions;

namespace DataTray.Tools.MsSqlAdmin;

/// <summary>
/// Runs an Always On plan — the failover's or the new-group wizard's — step by step, each on the instance it
/// names, reporting every step as a checklist row so a plan that stops halfway shows exactly how far it got.
/// </summary>
internal static partial class AgStepRunner
{
/// <summary>How long a wait step polls before giving up.</summary>
internal static readonly TimeSpan WaitLimit = TimeSpan.FromMinutes(2);

/// <summary>How long a <see cref="AgStep.Retries"/> step keeps being retried.</summary>
internal static readonly TimeSpan RetryWindow = TimeSpan.FromSeconds(30);

public static async Task RunAsync(
IReadOnlyList<AgStep> steps,
Func<string, ReplicaConnection?> connectionFor,
IProgress<ToolProgress> progress,
CancellationToken ct)
{
// Every connection in hand before the first statement, so no plan stops halfway for want of one.
var runners = steps.Select(s => s.Replica).Distinct(StringComparer.OrdinalIgnoreCase)
.ToDictionary(r => r, r => connectionFor(r)
?? throw new InvalidOperationException($"No verified connection to {r}. Nothing was run."), StringComparer.OrdinalIgnoreCase);
var captured = new Dictionary<string, string>(StringComparer.Ordinal);

for (var i = 0; i < steps.Count; i++)
{
var step = steps[i];
var key = $"step{i}";
var label = $"{i + 1}. {step.Replica}: {step.Purpose}";
progress.Report(new ToolProgress(label, (double)i / steps.Count, key, ToolItemStatus.Running));

var runner = runners[step.Replica];
var sql = Substitute(step.Sql, captured);
try
{
if (step.IsWait)
{
await WaitForZeroAsync(runner, sql, ct);
}
else if (step.CaptureAs is { } name)
{
var result = await runner.QueryAsync(sql, ct);
captured[name] = Literal(result.Rows.FirstOrDefault()?[0])
?? throw new InvalidOperationException($"{step.Purpose} returned nothing on {step.Replica}.");
}
else if (step.Retries)
{
await RetryAsync(() => runner.Provider.ExecuteDdlAsync(runner.Profile, sql, ct), ct);
}
else
{
await runner.Provider.ExecuteDdlAsync(runner.Profile, sql, ct);
}
}
catch
{
progress.Report(new ToolProgress(label, null, key, ToolItemStatus.Error));
throw;
}

progress.Report(new ToolProgress(label, (double)(i + 1) / steps.Count, key, ToolItemStatus.Done));
}
}

/// <summary>The steps as the script the user reviews — also what a tool's <c>ExecuteAsync</c> compares
/// against the plan it rebuilds from fresh state, so a plan that changed since review is refused, not run.
/// Captured values stay as their <c>$(…)</c> tokens: they do not exist until the run reads them.</summary>
public static string Script(IReadOnlyList<AgStep> steps)
{
var sb = new StringBuilder();
for (var i = 0; i < steps.Count; i++)
{
var step = steps[i];
sb.Append("-- ").Append(i + 1).Append(". on ").Append(step.Replica).Append(": ").AppendLine(step.Purpose);
sb.AppendLine(step.Sql.TrimEnd());
sb.AppendLine();
}

return sb.ToString().TrimEnd();
}

/// <summary>Replace every <c>$(cert:NAME)</c> with the value captured under it — only that prefix, so an object
/// that merely has <c>$(</c> in its name is left alone. A token nothing captured is an
/// error, not an empty string: an unfilled certificate must never reach the server as SQL.</summary>
internal static string Substitute(string sql, IReadOnlyDictionary<string, string> captured) =>
Token().Replace(sql, m => captured.TryGetValue(m.Groups[1].Value, out var v)
? v
: throw new InvalidOperationException($"Nothing was captured for $({m.Groups[1].Value})."));

/// <summary>A captured value as a T-SQL literal: binary as <c>0x…</c> (a certificate), anything else refused.</summary>
internal static string? Literal(object? value) => value switch
{
byte[] { Length: > 0 } bytes => "0x" + Convert.ToHexString(bytes),
_ => null
};

private static async Task RetryAsync(Func<Task> run, CancellationToken ct)
{
var deadline = DateTime.UtcNow + RetryWindow;
while (true)
{
try
{
await run();
return;
}
catch (Exception ex) when (ex is not OperationCanceledException && DateTime.UtcNow < deadline)
{
await Task.Delay(TimeSpan.FromSeconds(2), ct);
}
}
}

private static async Task WaitForZeroAsync(ReplicaConnection runner, string sql, CancellationToken ct)
{
var deadline = DateTime.UtcNow + WaitLimit;
while (true)
{
var result = await runner.QueryAsync(sql, ct);
var remaining = result.Rows.Count == 0 ? -1 : Convert.ToInt32(result.Rows[0][0]);
if (remaining == 0)
{
return;
}

if (DateTime.UtcNow > deadline)
{
throw new TimeoutException($"Still {remaining} after {WaitLimit.TotalMinutes:0} minutes of waiting. Stopped here; the steps before this one ran, nothing after it did.");
}

await Task.Delay(TimeSpan.FromSeconds(2), ct);
}
}

[GeneratedRegex(@"\$\((cert:[^)]+)\)")]
private static partial Regex Token();
}
Loading
Loading