Files
git.stella-ops.org/docs/router/05-Step.md
2025-12-02 18:38:32 +02:00

555 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.