router planning

This commit is contained in:
master
2025-12-02 18:38:32 +02:00
parent 790801f329
commit 0c9e8d5d18
15 changed files with 6439 additions and 0 deletions

554
docs/router/05-Step.md Normal file
View File

@@ -0,0 +1,554 @@
For this step, the goal is: the gateway can accept an HTTP request, route it to **one** microservice over the **InMemory** transport, get a response, and return it to the client.
No health/heartbeat yet. No streaming yet. Just: HTTP → InMemory → microservice → InMemory → HTTP.
Ill assume youre still in the InMemory world and not touching TCP/UDP/RabbitMQ at this stage.
---
## 0. Preconditions
Before you start:
* `StellaOps.Router.Common` exists and exposes:
* `EndpointDescriptor`, `ConnectionState`, `Frame`, `FrameType`, `TransportType`, `RoutingDecision`.
* Interfaces: `IGlobalRoutingState`, `IRoutingPlugin`, `ITransportClient`.
* `StellaOps.Microservice` minimal handshake & dispatch is in place (from your “step 4”):
* Microservice can:
* Discover endpoints.
* Connect to an InMemory router client.
* Send HELLO.
* Receive REQUEST and send RESPONSE.
* Gateway project exists (`StellaOps.Gateway.WebService`) and runs as a basic ASP.NET Core app.
If anything in that list is not true, fix it first or adjust the plan accordingly.
---
## 1. Implement an InMemory transport “hub”
You need a simple in-process component that:
* Keeps track of “connections” from microservices.
* Delivers frames from the gateway to the correct microservice and back.
You can host this either:
* In a dedicated **test/support** assembly, or
* In the gateway project but marked as “dev-only” transport.
For this step, keep it simple and in-memory.
### 1.1 Define an InMemory router hub
Conceptually:
```csharp
public interface IInMemoryRouterHub
{
// Called by microservice side to register a new connection
Task<string> RegisterMicroserviceAsync(
InstanceDescriptor instance,
IReadOnlyList<EndpointDescriptor> endpoints,
Func<Frame, Task> onFrameFromGateway,
CancellationToken ct);
// Called by microservice when it wants to send a frame to the gateway
Task SendFromMicroserviceAsync(string connectionId, Frame frame, CancellationToken ct);
// Called by gateway transport client when sending a frame to a microservice
Task<Frame> SendFromGatewayAsync(string connectionId, Frame frame, CancellationToken ct);
}
```
Internally, the hub maintains per-connection data:
* `ConnectionId`
* `InstanceDescriptor`
* Endpoints
* Delegate `onFrameFromGateway` (microservice receiver)
For minimal routing you can start by:
* Only supporting `SendFromGatewayAsync` for REQUEST and returning RESPONSE.
* For now, heartbeat frames can be ignored or stubbed.
### 1.2 Connect the microservice side
Your `InMemoryMicroserviceConnection` (from step 4) should:
* Call `RegisterMicroserviceAsync` on the hub when it sends HELLO:
* Get `connectionId`.
* Provide a handler `onFrameFromGateway` that:
* Dispatches REQUEST frames via `IEndpointDispatcher`.
* Sends RESPONSE frames back via `SendFromMicroserviceAsync`.
This is mostly microservice work; you should already have most of it outlined.
---
## 2. Implement an InMemory `ITransportClient` in the gateway
Now focus on the gateway side.
**Project:** `StellaOps.Gateway.WebService` (or a small internal infra class in the same project)
### 2.1 `InMemoryTransportClient`
Implement `ITransportClient` using the `IInMemoryRouterHub`:
```csharp
public sealed class InMemoryTransportClient : ITransportClient
{
private readonly IInMemoryRouterHub _hub;
public InMemoryTransportClient(IInMemoryRouterHub hub)
{
_hub = hub;
}
public Task<Frame> SendRequestAsync(
ConnectionState connection,
Frame requestFrame,
TimeSpan timeout,
CancellationToken ct)
{
// connection.ConnectionId must be set when HELLO is processed
return _hub.SendFromGatewayAsync(connection.ConnectionId, requestFrame, ct);
}
public Task SendCancelAsync(ConnectionState connection, Guid correlationId, string? reason = null)
=> Task.CompletedTask; // no-op at this stage
public Task SendStreamingAsync(
ConnectionState connection,
Frame requestHeader,
Stream requestBody,
Func<Stream, Task> readResponseBody,
PayloadLimits limits,
CancellationToken ct)
=> throw new NotSupportedException("Streaming not implemented for InMemory in this step.");
}
```
For now:
* Ignore streaming.
* Ignore cancel.
* Just call `SendFromGatewayAsync` and get a response frame.
### 2.2 Register it in DI
In gateway `Program.cs` or a DI setup:
```csharp
services.AddSingleton<IInMemoryRouterHub, InMemoryRouterHub>(); // your hub implementation
services.AddSingleton<ITransportClient, InMemoryTransportClient>();
```
Youll later swap this with real transport clients (TCP, UDP, Rabbit), but for now everything uses InMemory.
---
## 3. Implement minimal `IGlobalRoutingState`
You now need the gateways internal view of:
* Which endpoints exist.
* Which connections serve them.
**Project:** `StellaOps.Gateway.WebService` or a small internal infra namespace.
### 3.1 In-memory implementation
Implement an `InMemoryGlobalRoutingState` something like:
```csharp
public sealed class InMemoryGlobalRoutingState : IGlobalRoutingState
{
private readonly object _lock = new();
private readonly Dictionary<(string, string), EndpointDescriptor> _endpoints = new();
private readonly List<ConnectionState> _connections = new();
public EndpointDescriptor? ResolveEndpoint(string method, string path)
{
lock (_lock)
{
_endpoints.TryGetValue((method, path), out var endpoint);
return endpoint;
}
}
public IReadOnlyList<ConnectionState> GetConnectionsFor(
string serviceName,
string version,
string method,
string path)
{
lock (_lock)
{
return _connections
.Where(c =>
c.Instance.ServiceName == serviceName &&
c.Instance.Version == version &&
c.Endpoints.ContainsKey((method, path)))
.ToList();
}
}
// Called when HELLO arrives from microservice
public void RegisterConnection(ConnectionState connection)
{
lock (_lock)
{
_connections.Add(connection);
foreach (var kvp in connection.Endpoints)
{
var key = kvp.Key; // (Method, Path)
var descriptor = kvp.Value;
// global endpoint map: any connection's descriptor is ok as "canonical"
_endpoints[(key.Method, key.Path)] = descriptor;
}
}
}
}
```
You will refine this later; for minimal routing it's enough.
### 3.2 Hook HELLO to `IGlobalRoutingState`
In your InMemory router hub, when a microservice registers (HELLO):
* Create a `ConnectionState`:
```csharp
var conn = new ConnectionState
{
ConnectionId = generatedConnectionId,
Instance = instanceDescriptor,
Status = InstanceHealthStatus.Healthy,
LastHeartbeatUtc = DateTime.UtcNow,
AveragePingMs = 0,
TransportType = TransportType.Udp, // or TransportType.Tcp logically for InMemory
Endpoints = endpointDescriptors.ToDictionary(
e => (e.Method, e.Path),
e => e)
};
```
* Call `InMemoryGlobalRoutingState.RegisterConnection(conn)`.
This gives the gateway a routing view as soon as HELLO is processed.
---
## 4. Implement HTTP pipeline middlewares for routing
Now, wire the gateway HTTP pipeline so that an incoming HTTP request is:
1. Resolved to a logical endpoint.
2. Routed to one connection.
3. Dispatched via InMemory transport.
### 4.1 EndpointResolutionMiddleware
This maps `(Method, Path)` to an `EndpointDescriptor`.
Create a middleware:
```csharp
public sealed class EndpointResolutionMiddleware
{
private readonly RequestDelegate _next;
public EndpointResolutionMiddleware(RequestDelegate next) => _next = next;
public async Task Invoke(HttpContext context, IGlobalRoutingState routingState)
{
var method = context.Request.Method;
var path = context.Request.Path.ToString();
var endpoint = routingState.ResolveEndpoint(method, path);
if (endpoint is null)
{
context.Response.StatusCode = StatusCodes.Status404NotFound;
await context.Response.WriteAsync("Endpoint not found");
return;
}
context.Items["Stella.EndpointDescriptor"] = endpoint;
await _next(context);
}
}
```
Register it in the pipeline:
```csharp
app.UseMiddleware<EndpointResolutionMiddleware>();
```
Before or after auth depending on your final pipeline; for minimal routing, order is not critical.
### 4.2 Minimal routing plugin (pick first connection)
Implement a very naive `IRoutingPlugin` just to get things moving:
```csharp
public sealed class NaiveRoutingPlugin : IRoutingPlugin
{
private readonly IGlobalRoutingState _state;
public NaiveRoutingPlugin(IGlobalRoutingState state) => _state = state;
public Task<RoutingDecision?> ChooseInstanceAsync(
RoutingContext context,
CancellationToken cancellationToken)
{
var endpoint = context.Endpoint;
var connections = _state.GetConnectionsFor(
endpoint.ServiceName,
endpoint.Version,
endpoint.Method,
endpoint.Path);
var chosen = connections.FirstOrDefault();
if (chosen is null)
return Task.FromResult<RoutingDecision?>(null);
var decision = new RoutingDecision
{
Endpoint = endpoint,
Connection = chosen,
TransportType = chosen.TransportType,
EffectiveTimeout = endpoint.DefaultTimeout
};
return Task.FromResult<RoutingDecision?>(decision);
}
}
```
Register it:
```csharp
services.AddSingleton<IGlobalRoutingState, InMemoryGlobalRoutingState>();
services.AddSingleton<IRoutingPlugin, NaiveRoutingPlugin>();
```
### 4.3 RoutingDecisionMiddleware
This middleware grabs the endpoint descriptor and asks the routing plugin for a connection.
```csharp
public sealed class RoutingDecisionMiddleware
{
private readonly RequestDelegate _next;
public RoutingDecisionMiddleware(RequestDelegate next) => _next = next;
public async Task Invoke(HttpContext context, IRoutingPlugin routingPlugin)
{
var endpoint = (EndpointDescriptor?)context.Items["Stella.EndpointDescriptor"];
if (endpoint is null)
{
context.Response.StatusCode = 500;
await context.Response.WriteAsync("Endpoint metadata missing");
return;
}
var routingContext = new RoutingContext
{
Endpoint = endpoint,
GatewayRegion = "not_used_yet", // youll fill this from GatewayNodeConfig later
HttpContext = context
};
var decision = await routingPlugin.ChooseInstanceAsync(routingContext, context.RequestAborted);
if (decision is null)
{
context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
await context.Response.WriteAsync("No instances available");
return;
}
context.Items["Stella.RoutingDecision"] = decision;
await _next(context);
}
}
```
Register it after `EndpointResolutionMiddleware`:
```csharp
app.UseMiddleware<RoutingDecisionMiddleware>();
```
### 4.4 TransportDispatchMiddleware
This middleware:
* Builds a REQUEST frame from HTTP.
* Uses `ITransportClient` to send it to the chosen connection.
* Writes the RESPONSE frame back to HTTP.
Minimal version (buffered, no streaming):
```csharp
public sealed class TransportDispatchMiddleware
{
private readonly RequestDelegate _next;
public TransportDispatchMiddleware(RequestDelegate next) => _next = next;
public async Task Invoke(
HttpContext context,
ITransportClient transportClient)
{
var decision = (RoutingDecision?)context.Items["Stella.RoutingDecision"];
if (decision is null)
{
context.Response.StatusCode = 500;
await context.Response.WriteAsync("Routing decision missing");
return;
}
// Read request body into memory (safe for minimal tests)
byte[] bodyBytes;
using (var ms = new MemoryStream())
{
await context.Request.Body.CopyToAsync(ms);
bodyBytes = ms.ToArray();
}
var requestPayload = new MinimalRequestPayload
{
Method = context.Request.Method,
Path = context.Request.Path.ToString(),
Body = bodyBytes
// headers can be ignored or added later
};
var requestFrame = new Frame
{
Type = FrameType.Request,
CorrelationId = Guid.NewGuid(),
Payload = SerializeRequestPayload(requestPayload)
};
var timeout = decision.EffectiveTimeout;
using var cts = CancellationTokenSource.CreateLinkedTokenSource(context.RequestAborted);
cts.CancelAfter(timeout);
Frame responseFrame;
try
{
responseFrame = await transportClient.SendRequestAsync(
decision.Connection,
requestFrame,
timeout,
cts.Token);
}
catch (OperationCanceledException)
{
context.Response.StatusCode = StatusCodes.Status504GatewayTimeout;
await context.Response.WriteAsync("Upstream timeout");
return;
}
var responsePayload = DeserializeResponsePayload(responseFrame.Payload);
context.Response.StatusCode = responsePayload.StatusCode;
foreach (var (k, v) in responsePayload.Headers)
{
context.Response.Headers[k] = v;
}
if (responsePayload.Body is { Length: > 0 })
{
await context.Response.Body.WriteAsync(responsePayload.Body);
}
}
}
```
Youll need minimal DTOs and serializers (`MinimalRequestPayload`, `MinimalResponsePayload`) just to move bytes. You can use JSON for now; protocol details will be formalized later.
Register it after `RoutingDecisionMiddleware`:
```csharp
app.UseMiddleware<TransportDispatchMiddleware>();
```
At this point, you no longer need ASP.NET controllers for microservice endpoints; you can have a catch-all pipeline.
---
## 5. Minimal end-to-end test
**Owner:** test agent, probably in `StellaOps.Gateway.WebService.Tests` (plus a simple host for microservice in tests)
Scenario:
1. Start an in-memory microservice host:
* It uses `AddStellaMicroservice`.
* It attaches to the same `IInMemoryRouterHub` instance as the gateway (created inside the test).
* It has a single endpoint:
* `[StellaEndpoint("GET", "/ping")]`
* Handler returns “pong”.
2. Start the gateway host:
* Inject the same `IInMemoryRouterHub`.
* Use middlewares: `EndpointResolutionMiddleware`, `RoutingDecisionMiddleware`, `TransportDispatchMiddleware`.
3. Invoke HTTP `GET /ping` against the gateway (using `WebApplicationFactory` or `TestServer`).
Assert:
* HTTP status 200.
* Body “pong”.
* The router hub saw:
* At least one HELLO frame.
* One REQUEST frame.
* One RESPONSE frame.
This proves:
* HELLO → gateway routing state population.
* Endpoint resolution → connection selection.
* InMemory transport client used.
* Minimal dispatch works.
---
## 6. Done criteria for “Gateway: minimal routing using InMemory plugin”
Youre done with this step when:
* A microservice can register with the gateway via InMemory.
* The gateways `IGlobalRoutingState` knows about endpoints and connections.
* The HTTP pipeline:
* Resolves an endpoint based on `(Method, Path)`.
* Asks `IRoutingPlugin` for a connection.
* Uses `ITransportClient` (InMemory) to send REQUEST and get RESPONSE.
* Returns the mapped HTTP response to the client.
* You have at least one automated test showing:
* `GET /ping` through gateway → InMemory → microservice → back to HTTP.
After this, youre ready to:
* Swap `NaiveRoutingPlugin` with the health/region-sensitive plugin you defined.
* Implement heartbeat and latency.
* Later replace InMemory with TCP/UDP/Rabbit without changing the HTTP pipeline.