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

16 KiB
Raw Blame History

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:

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:

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:

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:

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:

    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:

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:

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:

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:

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.

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:

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):

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:

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.