router planning
This commit is contained in:
554
docs/router/05-Step.md
Normal file
554
docs/router/05-Step.md
Normal 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.
|
||||
|
||||
I’ll assume you’re 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>();
|
||||
```
|
||||
|
||||
You’ll later swap this with real transport clients (TCP, UDP, Rabbit), but for now everything uses InMemory.
|
||||
|
||||
---
|
||||
|
||||
## 3. Implement minimal `IGlobalRoutingState`
|
||||
|
||||
You now need the gateway’s 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", // you’ll 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
You’ll 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”
|
||||
|
||||
You’re done with this step when:
|
||||
|
||||
* A microservice can register with the gateway via InMemory.
|
||||
* The gateway’s `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, you’re 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.
|
||||
Reference in New Issue
Block a user