Skip to content

Commit 347e729

Browse files
committed
Implement Multi-Machine Orchestration Core
1 parent 41c1950 commit 347e729

6 files changed

Lines changed: 142 additions & 7 deletions

File tree

src/GsdOrchestrator.Tests/GoalSchedulerTests.cs

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,27 @@ public sealed class GoalSchedulerTests : IDisposable
88
{
99
private readonly string _root = Path.Combine(Path.GetTempPath(), $"scheduler-{Guid.NewGuid():N}");
1010

11+
private sealed class FakeNodeRegistry : INodeRegistry
12+
{
13+
public Task RegisterAsync(string nodeId, CancellationToken cancellationToken = default) => Task.CompletedTask;
14+
public Task HeartbeatAsync(string nodeId, CancellationToken cancellationToken = default) => Task.CompletedTask;
15+
public Task<IReadOnlyList<string>> GetActiveNodesAsync(CancellationToken cancellationToken = default) => Task.FromResult<IReadOnlyList<string>>(["worker-1"]);
16+
}
17+
18+
private GoalScheduler CreateScheduler(IGoalStore store)
19+
{
20+
var nodeRegistry = new FakeNodeRegistry();
21+
var options = Microsoft.Extensions.Options.Options.Create(new NodeSettings { NodeId = "worker-1" });
22+
return new GoalScheduler(store, nodeRegistry, options, NullLogger<GoalScheduler>.Instance);
23+
}
24+
1125
[Fact]
1226
public async Task TryAcquireLease_IncompleteDependency_IsDenied()
1327
{
1428
var store = await CreateStore();
1529
var aggregate = AggregateWithTwoItems(WorkItemStatus.Pending);
1630
await store.SaveAsync(aggregate);
17-
var scheduler = new GoalScheduler(store, NullLogger<GoalScheduler>.Instance);
31+
var scheduler = CreateScheduler(store);
1832

1933
var lease = await scheduler.TryAcquireLeaseAsync(Request("work-2"));
2034

@@ -26,7 +40,7 @@ public async Task TryAcquireLease_CompetingCalls_RespectsGlobalLimitAtomically()
2640
{
2741
var store = await CreateStore();
2842
await store.SaveAsync(IndependentAggregate());
29-
var scheduler = new GoalScheduler(store, NullLogger<GoalScheduler>.Instance);
43+
var scheduler = CreateScheduler(store);
3044

3145
var results = await Task.WhenAll(
3246
scheduler.TryAcquireLeaseAsync(Request("work-1", global: 1)),
@@ -42,7 +56,7 @@ public async Task TryAcquireLease_ProviderOrRepositoryLimit_DeniesSecond(int glo
4256
{
4357
var store = await CreateStore();
4458
await store.SaveAsync(IndependentAggregate());
45-
var scheduler = new GoalScheduler(store, NullLogger<GoalScheduler>.Instance);
59+
var scheduler = CreateScheduler(store);
4660
Assert.NotNull(await scheduler.TryAcquireLeaseAsync(Request("work-1", global, provider, repository)));
4761
Assert.Null(await scheduler.TryAcquireLeaseAsync(Request("work-2", global, provider, repository)));
4862
}
@@ -52,7 +66,7 @@ public async Task RecoverExpiredLeases_AllowsReacquire()
5266
{
5367
var store = await CreateStore();
5468
await store.SaveAsync(IndependentAggregate());
55-
var scheduler = new GoalScheduler(store, NullLogger<GoalScheduler>.Instance);
69+
var scheduler = CreateScheduler(store);
5670
var now = DateTimeOffset.UtcNow;
5771
Assert.NotNull(await scheduler.TryAcquireLeaseAsync(
5872
new LeaseRequest("goal-1", "work-1", "worker-1", now.AddMinutes(-2), now.AddMinutes(-1), 3, 3, 3)));
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
using System.Net.Http.Json;
2+
using System.Text.Json;
3+
using Microsoft.Extensions.Logging;
4+
5+
namespace GsdOrchestrator.Scheduling;
6+
7+
public sealed class ApiGoalStore : IGoalStore
8+
{
9+
private readonly HttpClient _httpClient;
10+
private readonly ILogger<ApiGoalStore> _logger;
11+
12+
public ApiGoalStore(HttpClient httpClient, ILogger<ApiGoalStore> logger)
13+
{
14+
_httpClient = httpClient;
15+
_logger = logger;
16+
}
17+
18+
public Task InitializeAsync(CancellationToken cancellationToken = default)
19+
{
20+
_logger.LogInformation("ApiGoalStore initialized");
21+
return Task.CompletedTask;
22+
}
23+
24+
public async Task SaveAsync(GoalAggregate aggregate, CancellationToken cancellationToken = default)
25+
{
26+
var response = await _httpClient.PostAsJsonAsync($"/api/goals/{aggregate.Goal.Id}", aggregate, cancellationToken);
27+
response.EnsureSuccessStatusCode();
28+
}
29+
30+
public async Task<GoalAggregate?> LoadAsync(string goalId, CancellationToken cancellationToken = default)
31+
{
32+
var response = await _httpClient.GetAsync($"/api/goals/{goalId}", cancellationToken);
33+
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
34+
return null;
35+
response.EnsureSuccessStatusCode();
36+
return await response.Content.ReadFromJsonAsync<GoalAggregate>(cancellationToken: cancellationToken);
37+
}
38+
39+
public async Task<LeaseRecord?> TryAcquireLeaseAsync(LeaseRequest request, CancellationToken cancellationToken = default)
40+
{
41+
var response = await _httpClient.PostAsJsonAsync($"/api/goals/{request.GoalId}/work-items/{request.WorkItemId}/leases", request, cancellationToken);
42+
if (response.StatusCode == System.Net.HttpStatusCode.Conflict || response.StatusCode == System.Net.HttpStatusCode.NotFound)
43+
return null;
44+
response.EnsureSuccessStatusCode();
45+
return await response.Content.ReadFromJsonAsync<LeaseRecord>(cancellationToken: cancellationToken);
46+
}
47+
48+
public async Task<int> RecoverExpiredLeasesAsync(DateTimeOffset now, CancellationToken cancellationToken = default)
49+
{
50+
var response = await _httpClient.PostAsJsonAsync("/api/leases/recover", new { Now = now }, cancellationToken);
51+
response.EnsureSuccessStatusCode();
52+
return await response.Content.ReadFromJsonAsync<int>(cancellationToken: cancellationToken);
53+
}
54+
55+
public async Task<bool> TryReserveIdempotencyKeyAsync(string goalId, string workItemId, string key, string effectType, CancellationToken cancellationToken = default)
56+
{
57+
var payload = new { GoalId = goalId, WorkItemId = workItemId, Key = key, EffectType = effectType };
58+
var response = await _httpClient.PostAsJsonAsync("/api/idempotency-keys/reserve", payload, cancellationToken);
59+
if (response.StatusCode == System.Net.HttpStatusCode.Conflict)
60+
return false;
61+
response.EnsureSuccessStatusCode();
62+
return true;
63+
}
64+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
using System.Net.Http.Json;
2+
using Microsoft.Extensions.Logging;
3+
4+
namespace GsdOrchestrator.Scheduling;
5+
6+
public sealed class ApiNodeRegistry : INodeRegistry
7+
{
8+
private readonly HttpClient _httpClient;
9+
private readonly ILogger<ApiNodeRegistry> _logger;
10+
11+
public ApiNodeRegistry(HttpClient httpClient, ILogger<ApiNodeRegistry> logger)
12+
{
13+
_httpClient = httpClient;
14+
_logger = logger;
15+
}
16+
17+
public async Task RegisterAsync(string nodeId, CancellationToken cancellationToken = default)
18+
{
19+
var response = await _httpClient.PostAsJsonAsync("/api/nodes/register", new { NodeId = nodeId }, cancellationToken);
20+
response.EnsureSuccessStatusCode();
21+
_logger.LogInformation("Node {NodeId} registered successfully.", nodeId);
22+
}
23+
24+
public async Task HeartbeatAsync(string nodeId, CancellationToken cancellationToken = default)
25+
{
26+
var response = await _httpClient.PostAsJsonAsync("/api/nodes/heartbeat", new { NodeId = nodeId }, cancellationToken);
27+
response.EnsureSuccessStatusCode();
28+
}
29+
30+
public async Task<IReadOnlyList<string>> GetActiveNodesAsync(CancellationToken cancellationToken = default)
31+
{
32+
var response = await _httpClient.GetAsync("/api/nodes", cancellationToken);
33+
response.EnsureSuccessStatusCode();
34+
var nodes = await response.Content.ReadFromJsonAsync<string[]>(cancellationToken: cancellationToken);
35+
return nodes ?? Array.Empty<string>();
36+
}
37+
}

src/GsdOrchestrator/Scheduling/GoalScheduler.cs

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,31 @@
11
using Microsoft.Extensions.Logging;
2+
using Microsoft.Extensions.Options;
23

34
namespace GsdOrchestrator.Scheduling;
45

56
public sealed class GoalScheduler
67
{
78
private readonly IGoalStore _store;
9+
private readonly INodeRegistry _nodeRegistry;
10+
private readonly string _nodeId;
811
private readonly ILogger<GoalScheduler> _logger;
912

10-
public GoalScheduler(IGoalStore store, ILogger<GoalScheduler> logger)
13+
public GoalScheduler(IGoalStore store, INodeRegistry nodeRegistry, IOptions<NodeSettings> options, ILogger<GoalScheduler> logger)
1114
{
1215
_store = store;
16+
_nodeRegistry = nodeRegistry;
17+
_nodeId = options.Value.NodeId;
1318
_logger = logger;
1419
}
1520

1621
public async Task<LeaseRecord?> TryAcquireLeaseAsync(LeaseRequest request, CancellationToken cancellationToken = default)
1722
{
18-
var lease = await _store.TryAcquireLeaseAsync(request, cancellationToken);
23+
var nodeRequest = request with { Owner = _nodeId };
24+
var lease = await _store.TryAcquireLeaseAsync(nodeRequest, cancellationToken);
1925
if (lease is null)
2026
_logger.LogDebug("Lease denied for goal {GoalId} work item {WorkItemId}", request.GoalId, request.WorkItemId);
2127
else
22-
_logger.LogInformation("Lease {LeaseId} acquired for goal {GoalId} work item {WorkItemId}", lease.Id, request.GoalId, request.WorkItemId);
28+
_logger.LogInformation("Lease {LeaseId} acquired for goal {GoalId} work item {WorkItemId} by node {NodeId}", lease.Id, request.GoalId, request.WorkItemId, _nodeId);
2329
return lease;
2430
}
2531

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
namespace GsdOrchestrator.Scheduling;
2+
3+
public interface INodeRegistry
4+
{
5+
Task RegisterAsync(string nodeId, CancellationToken cancellationToken = default);
6+
Task HeartbeatAsync(string nodeId, CancellationToken cancellationToken = default);
7+
Task<IReadOnlyList<string>> GetActiveNodesAsync(CancellationToken cancellationToken = default);
8+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
namespace GsdOrchestrator.Scheduling;
2+
3+
public sealed class NodeSettings
4+
{
5+
public string NodeId { get; set; } = Environment.MachineName;
6+
}

0 commit comments

Comments
 (0)