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

520
docs/router/04-Step.md Normal file
View File

@@ -0,0 +1,520 @@
For this step, the goal is: a microservice that can:
* Start up with `AddStellaMicroservice(...)`
* Discover its endpoints from attributes
* Connect to the router (via InMemory transport)
* Send a HELLO with identity + endpoints
* Receive a REQUEST and return a RESPONSE
No streaming, no cancellation, no heartbeat yet. Pure minimal handshake & dispatch.
---
## 0. Preconditions
Before your agents start this step, you should have:
* `StellaOps.Router.Common` contracts in place (enums, `EndpointDescriptor`, `ConnectionState`, `Frame`, etc.).
* The solution skeleton and project references configured.
* A **stub** InMemory transport “router harness” (at least a place to park the future InMemory transport). Even if its not fully implemented, assume it will expose:
* A way for a microservice to “connect” and register itself.
* A way to deliver frames from router to microservice and back.
If InMemory isnt built yet, the microservice code should be written *against abstractions* so you can plug it in later.
---
## 1. Define microservice public surface (SDK contract)
**Project:** `__Libraries/StellaOps.Microservice`
**Owner:** microservice SDK agent
Purpose: give product teams a stable way to define services and endpoints without caring about transports.
### 1.1 Options
Make sure `StellaMicroserviceOptions` matches the spec:
```csharp
public sealed class StellaMicroserviceOptions
{
public string ServiceName { get; set; } = string.Empty;
public string Version { get; set; } = string.Empty;
public string Region { get; set; } = string.Empty;
public string InstanceId { get; set; } = string.Empty;
public IList<RouterEndpointConfig> Routers { get; set; } = new List<RouterEndpointConfig>();
public string? ConfigFilePath { get; set; }
}
public sealed class RouterEndpointConfig
{
public string Host { get; set; } = string.Empty;
public int Port { get; set; }
public TransportType TransportType { get; set; }
}
```
`Routers` is mandatory: without at least one router configured, the SDK should refuse to start later (that policy can be enforced in the handshake stage).
### 1.2 Public endpoint abstractions
Define:
* Attribute for endpoint identity:
```csharp
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
public sealed class StellaEndpointAttribute : Attribute
{
public string Method { get; }
public string Path { get; }
public StellaEndpointAttribute(string method, string path)
{
Method = method;
Path = path;
}
}
```
* Raw handler:
```csharp
public sealed class RawRequestContext
{
public string Method { get; init; } = string.Empty;
public string Path { get; init; } = string.Empty;
public IReadOnlyDictionary<string,string> Headers { get; init; } =
new Dictionary<string,string>();
public Stream Body { get; init; } = Stream.Null;
public CancellationToken CancellationToken { get; init; }
}
public sealed class RawResponse
{
public int StatusCode { get; set; } = 200;
public IDictionary<string,string> Headers { get; } =
new Dictionary<string,string>();
public Func<Stream,Task>? WriteBodyAsync { get; set; } // may be null
}
public interface IRawStellaEndpoint
{
Task<RawResponse> HandleAsync(RawRequestContext ctx);
}
```
* Typed convenience interfaces (used later, but define now):
```csharp
public interface IStellaEndpoint<TRequest,TResponse>
{
Task<TResponse> HandleAsync(TRequest request, CancellationToken ct);
}
public interface IStellaEndpoint<TResponse>
{
Task<TResponse> HandleAsync(CancellationToken ct);
}
```
At this step, you dont need to implement adapters yet, but the signatures must be fixed.
### 1.3 Registration extension
Extend `AddStellaMicroservice` to wire options + a few internal services:
```csharp
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddStellaMicroservice(
this IServiceCollection services,
Action<StellaMicroserviceOptions> configure)
{
services.Configure(configure);
services.AddSingleton<IEndpointCatalog, EndpointCatalog>(); // to be implemented
services.AddSingleton<IEndpointDispatcher, EndpointDispatcher>(); // to be implemented
services.AddHostedService<MicroserviceBootstrapHostedService>(); // handshake loop
return services;
}
}
```
This still compiles with empty implementations; you fill them in next steps.
---
## 2. Endpoint discovery (reflection only for now)
**Project:** `StellaOps.Microservice`
**Owner:** SDK agent
Goal: given the entry assembly, build:
* A list of `EndpointDescriptor` objects (from Common).
* A mapping `(Method, Path) -> handler type` used for dispatch.
### 2.1 Internal types
Define an internal representation:
```csharp
internal sealed class EndpointRegistration
{
public EndpointDescriptor Descriptor { get; init; } = default!;
public Type HandlerType { get; init; } = default!;
}
```
Define an interface for discovery:
```csharp
internal interface IEndpointDiscovery
{
IReadOnlyList<EndpointRegistration> DiscoverEndpoints(StellaMicroserviceOptions options);
}
```
### 2.2 Implement reflection-based discovery
Create `ReflectionEndpointDiscovery`:
* Scan the entry assembly (and optionally referenced assemblies) for classes that:
* Have `StellaEndpointAttribute`.
* Implement either:
* `IRawStellaEndpoint`, or
* `IStellaEndpoint<,>`, or
* `IStellaEndpoint<>`.
* For each `[StellaEndpoint]` usage:
* Create `EndpointDescriptor` with:
* `ServiceName` = `options.ServiceName`.
* `Version` = `options.Version`.
* `Method`, `Path` from attribute.
* `DefaultTimeout` = some sensible default (e.g. `TimeSpan.FromSeconds(30)`; refine later).
* `SupportsStreaming` = `false` (for now).
* `RequiringClaims` = empty array (for now).
* Create `EndpointRegistration` with `Descriptor` + `HandlerType`.
* Return the list.
Wire it into DI:
```csharp
services.AddSingleton<IEndpointDiscovery, ReflectionEndpointDiscovery>();
```
---
## 3. Endpoint catalog & dispatcher (microservice internal)
**Project:** `StellaOps.Microservice`
**Owner:** SDK agent
Goal: presence of:
* A catalog holding endpoints and descriptors.
* A dispatcher that takes frames and calls handlers.
### 3.1 Endpoint catalog
Define:
```csharp
internal interface IEndpointCatalog
{
IReadOnlyList<EndpointDescriptor> Descriptors { get; }
bool TryGetHandler(string method, string path, out EndpointRegistration endpoint);
}
internal sealed class EndpointCatalog : IEndpointCatalog
{
private readonly Dictionary<(string Method, string Path), EndpointRegistration> _map;
public IReadOnlyList<EndpointDescriptor> Descriptors { get; }
public EndpointCatalog(IEndpointDiscovery discovery,
IOptions<StellaMicroserviceOptions> optionsAccessor)
{
var options = optionsAccessor.Value;
var registrations = discovery.DiscoverEndpoints(options);
_map = registrations.ToDictionary(
r => (r.Descriptor.Method, r.Descriptor.Path),
r => r,
StringComparer.OrdinalIgnoreCase);
Descriptors = registrations.Select(r => r.Descriptor).ToArray();
}
public bool TryGetHandler(string method, string path, out EndpointRegistration endpoint) =>
_map.TryGetValue((method, path), out endpoint!);
}
```
You can refine path normalization later; for now, keep it simple.
### 3.2 Endpoint dispatcher
Define:
```csharp
internal interface IEndpointDispatcher
{
Task<Frame> HandleRequestAsync(Frame requestFrame, CancellationToken ct);
}
```
Implement `EndpointDispatcher` with minimal behavior:
1. Decode `requestFrame.Payload` into a small DTO carrying:
* Method
* Path
* Headers (if you already have a format; if not, assume no headers in v0)
* Body bytes
For this step, you can stub decoding as:
* Payload = raw body bytes.
* Method/Path are carried separately in frame header or in a simple DTO; decide a minimal interim format and write it down.
2. Use `IEndpointCatalog.TryGetHandler(method, path, ...)`:
* If not found:
* Build a `RawResponse` with status 404 and empty body.
3. If handler implements `IRawStellaEndpoint`:
* Instantiate via DI (`IServiceProvider.GetRequiredService(handlerType)`).
* Build `RawRequestContext` with:
* Method, Path, Headers, Body (`new MemoryStream(bodyBytes)` for now).
* `CancellationToken` = `ct`.
* Call `HandleAsync`.
* Convert `RawResponse` into a response frame payload.
4. If handler implements `IStellaEndpoint<,>` (typed):
* For now, **you can skip typed handling** or wire a very simple JSON-based adapter if you want to unlock it early. The focus in this step is the raw path; typed adapters can come in the next iteration.
Return a `Frame` with:
* `Type = FrameType.Response`
* `CorrelationId` = `requestFrame.CorrelationId`
* `Payload` = encoded response (status + body bytes).
No streaming, no cancellation logic beyond passing `ct` through — router wont cancel yet.
---
## 4. Minimal handshake hosted service (using InMemory)
**Project:** `StellaOps.Microservice`
**Owner:** SDK agent
This is where the microservice actually “talks” to the router.
### 4.1 Define a microservice connection abstraction
Your SDK should not depend directly on InMemory; define an internal abstraction:
```csharp
internal interface IMicroserviceConnection
{
Task StartAsync(CancellationToken ct);
Task StopAsync(CancellationToken ct);
}
```
The implementation for this step will target the InMemory transport; later you can add TCP/TLS/RabbitMQ versions.
### 4.2 Implement InMemory microservice connection
Assuming you have or will have an `IInMemoryRouter` (or similar) dev harness, implement:
```csharp
internal sealed class InMemoryMicroserviceConnection : IMicroserviceConnection
{
private readonly IEndpointCatalog _catalog;
private readonly IEndpointDispatcher _dispatcher;
private readonly IOptions<StellaMicroserviceOptions> _options;
private readonly IInMemoryRouterClient _routerClient; // dev-only abstraction
public InMemoryMicroserviceConnection(
IEndpointCatalog catalog,
IEndpointDispatcher dispatcher,
IOptions<StellaMicroserviceOptions> options,
IInMemoryRouterClient routerClient)
{
_catalog = catalog;
_dispatcher = dispatcher;
_options = options;
_routerClient = routerClient;
}
public async Task StartAsync(CancellationToken ct)
{
var opts = _options.Value;
// Build HELLO payload from options + catalog.Descriptors
var helloPayload = BuildHelloPayload(opts, _catalog.Descriptors);
await _routerClient.ConnectAsync(opts, ct);
await _routerClient.SendHelloAsync(helloPayload, ct);
// Start background receive loop
_ = Task.Run(() => ReceiveLoopAsync(ct), ct);
}
public Task StopAsync(CancellationToken ct)
{
// For now: ask routerClient to disconnect; finer handling later
return _routerClient.DisconnectAsync(ct);
}
private async Task ReceiveLoopAsync(CancellationToken ct)
{
await foreach (var frame in _routerClient.GetIncomingFramesAsync(ct))
{
if (frame.Type == FrameType.Request)
{
var response = await _dispatcher.HandleRequestAsync(frame, ct);
await _routerClient.SendFrameAsync(response, ct);
}
else
{
// Ignore other frame types in this minimal step
}
}
}
}
```
`IInMemoryRouterClient` is whatever dev harness you build for the in-memory transport; the exact shape is not important for this steps planning, only that it provides:
* `ConnectAsync`
* `SendHelloAsync`
* `GetIncomingFramesAsync` (async stream of frames)
* `SendFrameAsync` for responses
* `DisconnectAsync`
### 4.3 Hosted service to bootstrap the connection
Implement `MicroserviceBootstrapHostedService`:
```csharp
internal sealed class MicroserviceBootstrapHostedService : IHostedService
{
private readonly IMicroserviceConnection _connection;
public MicroserviceBootstrapHostedService(IMicroserviceConnection connection)
{
_connection = connection;
}
public Task StartAsync(CancellationToken cancellationToken) =>
_connection.StartAsync(cancellationToken);
public Task StopAsync(CancellationToken cancellationToken) =>
_connection.StopAsync(cancellationToken);
}
```
Wire `IMicroserviceConnection` to `InMemoryMicroserviceConnection` in DI for now:
```csharp
services.AddSingleton<IMicroserviceConnection, InMemoryMicroserviceConnection>();
```
In a later phase, youll swap this to transport-specific connectors.
---
## 5. End-to-end smoke test (InMemory only)
**Project:** `StellaOps.Microservice.Tests` + a minimal InMemory router test harness
**Owner:** test agent
Goal: prove that minimal handshake & dispatch works in memory.
1. Build a trivial test microservice:
* Define a handler:
```csharp
[StellaEndpoint("GET", "/ping")]
public sealed class PingEndpoint : IRawStellaEndpoint
{
public Task<RawResponse> HandleAsync(RawRequestContext ctx)
{
var resp = new RawResponse { StatusCode = 200 };
resp.Headers["Content-Type"] = "text/plain";
resp.WriteBodyAsync = stream => stream.WriteAsync(
Encoding.UTF8.GetBytes("pong"));
return Task.FromResult(resp);
}
}
```
2. Test harness:
* Spin up:
* An instance of the microservice host (generic HostBuilder).
* An in-memory “router” that:
* Accepts HELLO from the microservice.
* Sends a single REQUEST frame for `GET /ping`.
* Receives the RESPONSE frame.
3. Assert:
* The HELLO includes the `/ping` endpoint.
* The REQUEST is dispatched to `PingEndpoint`.
* The RESPONSE has status 200 and body “pong”.
This verifies that:
* `AddStellaMicroservice` wires discovery, catalog, dispatcher, bootstrap.
* The microservice sends HELLO on connect.
* The microservice can handle at least one request via InMemory.
---
## 6. Done criteria for “minimal handshake & dispatch”
You can consider this step complete when:
* `StellaOps.Microservice` exposes:
* Options.
* Attribute & handler interfaces (raw + typed).
* `AddStellaMicroservice` registering discovery, catalog, dispatcher, and hosted service.
* The microservice can:
* Discover endpoints via reflection.
* Build a `HELLO` payload and send it over InMemory on startup.
* Receive a `REQUEST` frame over InMemory.
* Dispatch that request to the correct handler.
* Return a `RESPONSE` frame.
Not yet required in this step:
* Streaming bodies.
* Heartbeats or health evaluation.
* Cancellation via CANCEL frames.
* Authority overrides for requiringClaims.
Those come in subsequent phases; right now you just want a working minimal vertical slice: InMemory microservice that says “HELLO” and responds to one simple request.