Add load balancing

This commit is contained in:
Stone_Red
2025-12-20 18:46:21 +01:00
parent 7c3ab3a154
commit 27558ba3f2
10 changed files with 322 additions and 46 deletions
@@ -0,0 +1,10 @@
namespace RemoteExec.Client;
public enum LoadBalancingStrategy
{
RoundRobin,
Random,
LeastConnections,
LeastActiveTasks,
ResourceAware
}
+144 -26
View File
@@ -2,6 +2,7 @@
using RemoteExec.Shared; using RemoteExec.Shared;
using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using System.Reflection; using System.Reflection;
using System.Text.Json; using System.Text.Json;
@@ -9,45 +10,101 @@ using System.Threading.Channels;
namespace RemoteExec.Client; namespace RemoteExec.Client;
public class RemoteExecutor(string url) public class RemoteExecutor
{ {
private readonly HubConnection _connection = private readonly List<HubConnection> connections = [];
new HubConnectionBuilder() private readonly ConcurrentDictionary<HubConnection, ServerMetrics> serverMetrics = new();
.WithUrl(url) private int _currentConnectionIndex = 0;
.WithAutomaticReconnect() private readonly Lock @lock = new Lock();
.Build(); private readonly LoadBalancingStrategy loadBalancingStrategy;
public event EventHandler<ServerMetricsUpdatedEventArgs>? MetricsUpdated;
public RemoteExecutor(string url) : this([url], LoadBalancingStrategy.RoundRobin)
{
}
public RemoteExecutor(string[] urls, LoadBalancingStrategy loadBalancingStrategy = LoadBalancingStrategy.RoundRobin)
{
this.loadBalancingStrategy = loadBalancingStrategy;
foreach (string url in urls)
{
HubConnection connection = new HubConnectionBuilder()
.WithUrl(url)
.WithAutomaticReconnect()
.Build();
connections.Add(connection);
}
}
public async Task StartAsync(CancellationToken cancellationToken = default) public async Task StartAsync(CancellationToken cancellationToken = default)
{ {
_ = _connection.On($"RequestAssembly", async (string assemblyName, Guid requestId) => List<Task> startTasks = [];
foreach (HubConnection connection in connections)
{ {
Assembly? assembly = AppDomain.CurrentDomain.GetAssemblies().FirstOrDefault(a => a.GetName().FullName == assemblyName); _ = connection.On<ServerMetrics>("MetricsUpdated", metrics =>
if (assembly == null)
{ {
assembly = Assembly.Load(new AssemblyName(assemblyName)); serverMetrics[connection] = metrics;
} MetricsUpdated?.Invoke(this, new ServerMetricsUpdatedEventArgs(connection, metrics));
});
byte[] dllBytes = await File.ReadAllBytesAsync(assembly.Location!); _ = connection.On($"RequestAssembly", async (string assemblyName, Guid requestId) =>
Channel<byte> channel = Channel.CreateUnbounded<byte>();
foreach (byte b in dllBytes)
{ {
await channel.Writer.WriteAsync(b); Assembly? assembly = AppDomain.CurrentDomain.GetAssemblies().FirstOrDefault(a => a.GetName().FullName == assemblyName) ?? Assembly.Load(new AssemblyName(assemblyName));
} byte[] dllBytes = await File.ReadAllBytesAsync(assembly.Location!);
channel.Writer.Complete(); Channel<byte> channel = Channel.CreateUnbounded<byte>();
await _connection.InvokeAsync("ProvideAssembly", requestId, channel.Reader); foreach (byte b in dllBytes)
}); {
await channel.Writer.WriteAsync(b);
}
await _connection.StartAsync(cancellationToken); channel.Writer.Complete();
await connection.InvokeAsync("ProvideAssembly", requestId, channel.Reader);
});
startTasks.Add(connection.StartAsync(cancellationToken)
.ContinueWith(async (task, state) =>
{
HubConnection conn = (HubConnection)state!;
serverMetrics[conn] = await conn.InvokeAsync<ServerMetrics>("GetMetrics", cancellationToken);
MetricsUpdated?.Invoke(this, new ServerMetricsUpdatedEventArgs(conn, serverMetrics[conn]));
}, connection, TaskScheduler.Default).Unwrap());
}
await Task.WhenAll(startTasks);
} }
public async Task StopAsync(CancellationToken cancellationToken = default) public async Task StopAsync(CancellationToken cancellationToken = default)
{ {
await _connection.StopAsync(cancellationToken); List<Task> stopTasks = [];
foreach (HubConnection connection in connections)
{
stopTasks.Add(connection.StopAsync(cancellationToken));
}
await Task.WhenAll(stopTasks);
}
public Dictionary<string, ServerMetrics> GetCurrentServerMetrics()
{
Dictionary<string, ServerMetrics> metrics = [];
foreach (HubConnection connection in connections)
{
if (serverMetrics.TryGetValue(connection, out ServerMetrics? newServerMetrics))
{
metrics[newServerMetrics.ServerId] = newServerMetrics;
}
}
return metrics;
} }
public bool TryExecute<TDelegate, TResult>(TDelegate del, out TResult? result, params object[] args) where TDelegate : Delegate public bool TryExecute<TDelegate, TResult>(TDelegate del, out TResult? result, params object[] args) where TDelegate : Delegate
@@ -118,7 +175,9 @@ public class RemoteExecutor(string url)
Arguments = args Arguments = args
}; };
RemoteExecutionResult result = _connection HubConnection connection = GetNextConnection();
RemoteExecutionResult result = connection
.InvokeAsync<RemoteExecutionResult>("Execute", request) .InvokeAsync<RemoteExecutionResult>("Execute", request)
.GetAwaiter() .GetAwaiter()
.GetResult(); .GetResult();
@@ -130,4 +189,63 @@ public class RemoteExecutor(string url)
return result.Result; return result.Result;
} }
}
private HubConnection GetNextConnection()
{
if (connections.Count == 0)
{
throw new InvalidOperationException("No connections available");
}
return loadBalancingStrategy switch
{
LoadBalancingStrategy.RoundRobin => GetRoundRobinConnection(),
LoadBalancingStrategy.Random => GetRandomConnection(),
LoadBalancingStrategy.LeastConnections => GetLeastConnections(),
LoadBalancingStrategy.LeastActiveTasks => GetLeastActiveTasksConnections(),
LoadBalancingStrategy.ResourceAware => GetResourceAwareConnections(),
_ => throw new NotSupportedException($"Load balancing strategy {loadBalancingStrategy} is not supported")
};
}
private HubConnection GetRoundRobinConnection()
{
lock (@lock)
{
HubConnection connection = connections[_currentConnectionIndex];
_currentConnectionIndex = (_currentConnectionIndex + 1) % connections.Count;
return connection;
}
}
private HubConnection GetRandomConnection()
{
int index = Random.Shared.Next(connections.Count);
return connections[index];
}
private HubConnection GetLeastConnections()
{
// Purely looks at how many clients are connected to the Hub
return connections.OrderBy(c => serverMetrics.TryGetValue(c, out ServerMetrics? m) ? m.ActiveConnections : 0).First();
}
private HubConnection GetLeastActiveTasksConnections()
{
return connections.OrderBy(c => serverMetrics.TryGetValue(c, out ServerMetrics? m) ? m.ActiveTasks : 0).First();
}
private HubConnection GetResourceAwareConnections()
{
return connections.OrderBy(c =>
{
if (!serverMetrics.TryGetValue(c, out ServerMetrics? m))
{
return 0;
}
// Simple heuristic: CPU percentage + (Memory in MB / 1024)
return m.CpuUsage + (m.TotalMemoryUsage / 1024 / 1024 / 100);
}).First();
}
}
@@ -0,0 +1,11 @@
using Microsoft.AspNetCore.SignalR.Client;
using RemoteExec.Shared;
namespace RemoteExec.Client;
public class ServerMetricsUpdatedEventArgs(HubConnection connection, ServerMetrics metrics) : EventArgs
{
public HubConnection Connection { get; } = connection;
public ServerMetrics Metrics { get; } = metrics;
}
@@ -3,6 +3,7 @@
using RemoteExec.Shared; using RemoteExec.Shared;
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Diagnostics;
using System.Reflection; using System.Reflection;
using System.Text.Json; using System.Text.Json;
using System.Threading.Channels; using System.Threading.Channels;
@@ -15,6 +16,11 @@ public class RemoteExecutionHub(ILogger<RemoteExecutionHub> logger) : Hub
private static readonly ConcurrentDictionary<Guid, TaskCompletionSource<byte[]>> pendingAssemblyRequests = new(); private static readonly ConcurrentDictionary<Guid, TaskCompletionSource<byte[]>> pendingAssemblyRequests = new();
private static DateTime lastMetricsTimestamp;
private static TimeSpan lastTotalProcessorTime;
private static int activeTasks = 0;
public override Task OnConnectedAsync() public override Task OnConnectedAsync()
{ {
RemoteJobAssemblyLoadContext assemblyLoadContext = new RemoteJobAssemblyLoadContext($"RemoteJob_{Guid.NewGuid()}"); RemoteJobAssemblyLoadContext assemblyLoadContext = new RemoteJobAssemblyLoadContext($"RemoteJob_{Guid.NewGuid()}");
@@ -39,6 +45,8 @@ public class RemoteExecutionHub(ILogger<RemoteExecutionHub> logger) : Hub
public async Task<RemoteExecutionResult> Execute(RemoteExecutionRequest req) public async Task<RemoteExecutionResult> Execute(RemoteExecutionRequest req)
{ {
_ = Interlocked.Increment(ref activeTasks);
try try
{ {
if (!connections.TryGetValue(Context.ConnectionId, out RemoteJobAssemblyLoadContext? assemblyLoadContext)) if (!connections.TryGetValue(Context.ConnectionId, out RemoteJobAssemblyLoadContext? assemblyLoadContext))
@@ -110,6 +118,23 @@ public class RemoteExecutionHub(ILogger<RemoteExecutionHub> logger) : Hub
object? result = method.Invoke(null, invokeArgs); object? result = method.Invoke(null, invokeArgs);
if (result is Task taskResult)
{
await taskResult.ConfigureAwait(false);
Type returnType = method.ReturnType;
if (returnType.IsGenericType && returnType.GetGenericTypeDefinition() == typeof(Task<>))
{
// For Task<T>, get the Result property
PropertyInfo resultProperty = returnType.GetProperty("Result")!;
result = resultProperty.GetValue(taskResult);
}
else
{
// For non-generic Task, result is null
result = null;
}
}
return new RemoteExecutionResult return new RemoteExecutionResult
{ {
Result = result Result = result
@@ -124,6 +149,10 @@ public class RemoteExecutionHub(ILogger<RemoteExecutionHub> logger) : Hub
Exception = ex.ToString() Exception = ex.ToString()
}; };
} }
finally
{
_ = Interlocked.Decrement(ref activeTasks);
}
} }
public async Task ProvideAssembly(Guid requestId, ChannelReader<byte> stream) public async Task ProvideAssembly(Guid requestId, ChannelReader<byte> stream)
@@ -146,6 +175,48 @@ public class RemoteExecutionHub(ILogger<RemoteExecutionHub> logger) : Hub
} }
} }
public async Task<ServerMetrics> GetMetrics()
{
return await GetServerMetrics();
}
public static async Task BroadcastMetricsAsync(IHubContext<RemoteExecutionHub> hubContext)
{
ServerMetrics metrics = await GetServerMetrics();
await hubContext.Clients.All.SendAsync("MetricsUpdated", metrics);
}
private static async Task<ServerMetrics> GetServerMetrics()
{
Process currentProcess = Process.GetCurrentProcess();
// Capture current values
DateTime currentTime = DateTime.UtcNow;
TimeSpan currentProcessorTime = currentProcess.TotalProcessorTime;
// Calculate the difference since the last check
double elapsedMs = (currentTime - lastMetricsTimestamp).TotalMilliseconds;
double cpuMsUsed = (currentProcessorTime - lastTotalProcessorTime).TotalMilliseconds;
// Calculate percentage: (Time Used / Time Elapsed) / Cores
// We multiply by 100 to get a 0-100 scale
double cpuUsagePercent = cpuMsUsed / elapsedMs / Environment.ProcessorCount * 100;
// Update static variables for the next call
lastMetricsTimestamp = currentTime;
lastTotalProcessorTime = currentProcessorTime;
return new ServerMetrics
{
ServerId = Environment.MachineName,
ActiveConnections = connections.Count,
ActiveTasks = activeTasks,
TotalMemoryUsage = currentProcess.WorkingSet64,
CpuUsage = Math.Clamp(Math.Round(cpuUsagePercent, 2), 0, 100),
Timestamp = currentTime
};
}
private async Task<Assembly> RequestAssemblyAsync(string assemblyName) private async Task<Assembly> RequestAssemblyAsync(string assemblyName)
{ {
try try
+8 -1
View File
@@ -1,4 +1,5 @@
using RemoteExec.Server.Hubs; using RemoteExec.Server.Hubs;
using RemoteExec.Server.Services;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args); WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
@@ -7,6 +8,10 @@ WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers(); builder.Services.AddControllers();
builder.Services.AddSignalR(); builder.Services.AddSignalR();
builder.Services.AddOpenApi(); builder.Services.AddOpenApi();
builder.Services.AddHealthChecks();
// Add metrics broadcast background service
builder.Services.AddHostedService<MetricsBroadcastService>();
WebApplication app = builder.Build(); WebApplication app = builder.Build();
@@ -18,10 +23,12 @@ if (app.Environment.IsDevelopment())
_ = app.MapOpenApi(); _ = app.MapOpenApi();
} }
app.UseHttpsRedirection(); //app.UseHttpsRedirection();
app.UseAuthorization(); app.UseAuthorization();
app.MapControllers(); app.MapControllers();
app.MapHealthChecks("/health");
await app.RunAsync(); await app.RunAsync();
@@ -0,0 +1,36 @@
using Microsoft.AspNetCore.SignalR;
using RemoteExec.Server.Hubs;
namespace RemoteExec.Server.Services;
public class MetricsBroadcastService(IHubContext<RemoteExecutionHub> hubContext, ILogger<MetricsBroadcastService> logger) : BackgroundService
{
private readonly TimeSpan _broadcastInterval = TimeSpan.FromSeconds(2);
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
logger.LogInformation("Metrics broadcast service started");
while (!stoppingToken.IsCancellationRequested)
{
try
{
await Task.Delay(_broadcastInterval, stoppingToken);
await RemoteExecutionHub.BroadcastMetricsAsync(hubContext);
}
catch (OperationCanceledException)
{
// Expected when service is stopping
break;
}
catch (Exception ex)
{
logger.LogError(ex, "Error broadcasting metrics");
}
}
logger.LogInformation("Metrics broadcast service stopped");
}
}
-12
View File
@@ -1,12 +0,0 @@
namespace RemoteExec.Server;
public class WeatherForecast
{
public DateOnly Date { get; set; }
public int TemperatureC { get; set; }
public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
public string? Summary { get; set; }
}
+12 -1
View File
@@ -4,5 +4,16 @@
"Default": "Information", "Default": "Information",
"Microsoft.AspNetCore": "Warning" "Microsoft.AspNetCore": "Warning"
} }
} },
"Kestrel": {
"Endpoints": {
"Http": {
"Url": "http://*:5000"
},
"Https": {
"Url": "https://*:5001"
}
}
},
"AllowedHosts": "*"
} }
+11
View File
@@ -0,0 +1,11 @@
namespace RemoteExec.Shared;
public sealed class ServerMetrics
{
public int ActiveConnections { get; set; }
public int ActiveTasks { get; set; }
public long TotalMemoryUsage { get; set; }
public double CpuUsage { get; set; }
public DateTime Timestamp { get; set; }
public string ServerId { get; set; } = string.Empty;
}
+19 -6
View File
@@ -2,18 +2,31 @@
using RemoteExec.Client; using RemoteExec.Client;
RemoteExecutor remoteExecutor = new RemoteExecutor("https://localhost:7109/remote"); // Single host example
await remoteExecutor.StartAsync(); RemoteExecutor singleHostExecutor = new RemoteExecutor("https://localhost:5001/remote");
bool success = remoteExecutor.TryExecute(Multiply, out int result, 2, 4); singleHostExecutor.MetricsUpdated += (sender, e) =>
{
Console.WriteLine($"[METRICS UPDATE] Server: {e.Metrics.ServerId}");
Console.WriteLine($" Active Connections: {e.Metrics.ActiveConnections}");
Console.WriteLine($" Pending Requests: {e.Metrics.ActiveTasks}");
Console.WriteLine($" CPU Usage: {e.Metrics.CpuUsage}%");
Console.WriteLine($" Memory: {e.Metrics.TotalMemoryUsage / 1024 / 1024} MB");
Console.WriteLine();
};
await singleHostExecutor.StartAsync();
bool success = singleHostExecutor.TryExecute(Multiply, out int result, 2, 4);
Console.WriteLine($"Success: {success}, Result: {result}"); Console.WriteLine($"Success: {success}, Result: {result}");
result = remoteExecutor.Execute<Func<int, int, int>, int>(Multiply, 3, 5); result = singleHostExecutor.Execute<Func<int, int, Task<int>>, int>(Multiply, 3, 5);
Console.WriteLine($"Result: {result}"); Console.WriteLine($"Result: {result}");
static int Multiply(int x, int y) await singleHostExecutor.StopAsync();
static async Task<int> Multiply(int x, int y)
{ {
await Task.Delay(5000);
return x.Multiply(y); return x.Multiply(y);
} }