Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,25 @@

## [Unreleased]

## [0.7.10] - 2026-07-22

### Added

- `SharpLinkMultiClusterClientBuilder`, `ISharpLinkMultiClusterClient`, and `SharpLinkClusterKey` coordinate multiple isolated child clients while routing each generated contract to exactly one cluster slot.
- `[assembly: SharpLinkClusterContractAssembly(cluster, typeof(Marker))]` generates a deterministic, weak-catalogued static route manifest. The generator rejects invalid cluster keys, missing generated manifests, and contradictory contract-assembly routes.
- Dynamic registration, unregister, and replacement now have explicit multi-cluster overloads. Contract-owning assemblies remain exclusive to one slot; dependency-only assemblies can remain independently owned by more than one slot.
- `AddSharpLinkMultiClusterClient` adds hosted startup, shutdown, and `ISharpLinkMultiClusterClientAccessor` without exposing child `ISharpLinkClient` instances through DI.

### Changed

- `SharpClientBuilder.Build()` keeps its existing complete generated-manifest snapshot behavior. The multi-cluster builder uses an internal filtered build context, so ordinary clients and the fixed-endpoint RPC hot path do not perform coordinator lookups or acquire new locks.
- Cluster selection is performed only by `Get<TContract>()`; generated proxies call their selected child channel directly and Protocol v2 frames, handshake capabilities, headers, and metadata do not carry a cluster key.

### Compatibility

- Ordinary single-client applications require no migration. Endpoint-cluster retry, admission, circuit-breaker, resolver, transport, authentication, and connection-pool behavior stays owned by each child client.
- Static NativeAOT routing remains manifest based. Runtime assembly registration continues to return the existing structured platform-not-supported result when unavailable.

## [0.7.9] - 2026-07-21

### 收敛
Expand Down
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
</PropertyGroup>
<!-- Nuget包信息 -->
<PropertyGroup>
<Version>0.7.9</Version>
<Version>0.7.10</Version>
<Authors>sunsi</Authors>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<PackageRequireLicenseAcceptance>false</PackageRequireLicenseAcceptance>
Expand Down
41 changes: 41 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,47 @@ dotnet run --project demo/SeparatedClient/SeparatedClient.csproj
[assembly: SharpLinkRpcContracts(typeof(MyContract1), typeof(MyContract2))]
```

## Multi-cluster clients

`SharpLinkMultiClusterClientBuilder` owns several isolated child clients. A contract is mapped to a
single child while its proxy is created; RPC calls made through that proxy do not consult a
coordinator, cluster name, or per-call routing context.

Declare static contract-assembly routes in the application or host assembly, not in a reusable
contract package:

```csharp
[assembly: SharpLinkClusterContractAssembly("orders", typeof(OrderContractsMarker))]
[assembly: SharpLinkClusterContractAssembly("payments", typeof(PaymentContractsMarker))]
```

Configure each slot with the existing child builder API. The `UseCluster` method inside the delegate
still configures endpoint topology for that one slot; it is not the multi-cluster coordinator API.

```csharp
var client = SharpLinkMultiClusterClientBuilder.Create()
.AddCluster("orders", child => child.UseTcp("127.0.0.1", 5101))
.AddCluster("payments", child => child.UseTcp("127.0.0.1", 5102))
.Build();

await client.ConnectAsync();
var orders = client.Get<IOrderService>();
var payments = client.Get<IPaymentService>();
```

Every slot is required by default. A slot intentionally reserved for plugins must explicitly opt in:

```csharp
.AddCluster("plugins", child => child.UseTcp("127.0.0.1", 5103),
slot => slot.AllowDynamicContracts = true)
```

Dynamic contracts are registered against an explicit slot with
`RegisterAssembly(cluster, assembly)`, `UnregisterAssemblyAsync(cluster, assembly, timeout)`, and
`ReplaceAssemblyAsync(cluster, oldAssembly, newAssembly, timeout)`. There is no default cluster,
per-call cluster override, cross-cluster retry, or cluster identifier on the wire. See
`doc/architecture-0.7.10.md` and `doc/migration-0.7.10.md` for lifecycle and migration details.

## 契约 Manifest 与兼容性基线

`SharpLink.Sdk` 包会把当前契约写到 `obj/<configuration>/<tfm>/SharpLink.Contracts.sharplink.json`。JSON 按 Contract、Method、DTO member、enum、union 与 Service route 的稳定 ID 排序,不包含时间戳或源码路径;`schemaFingerprint` 覆盖规范化后的完整内容,可直接作为 CI 构建产物保存。
Expand Down
1 change: 1 addition & 0 deletions Sharplink.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,6 @@
<Project Path="test/SharpLink.DynamicServices/SharpLink.DynamicServices.csproj" />
<Project Path="test/SharpLink.UnitTests/SharpLink.UnitTests.csproj" />
<Project Path="test/SharpLink.Generator.Tests/SharpLink.Generator.Tests.csproj" />
<Project Path="test/SharpLink.AotContracts/SharpLink.AotContracts.csproj" />
<Project Path="test/SharpLink.AotSmoke/SharpLink.AotSmoke.csproj" />
</Solution>
1 change: 1 addition & 0 deletions demo/SeparatedClient/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using SharpLink.Sdk;

[assembly: SharpLinkRpcContracts(typeof(IGreetingService))]
[assembly: SharpLinkClusterContractAssembly("greetings", typeof(IGreetingService))]

const int port = 19110;

Expand Down
36 changes: 36 additions & 0 deletions doc/architecture-0.7.10.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# SharpLink 0.7.10 multi-cluster architecture

## Ownership

`SharpLinkMultiClusterClient` is a coordinator, not a flattened endpoint topology. Each slot owns an ordinary `SharpLinkClient`, including endpoint selection, retry exclusion, circuit breaker, admission, resolver, transport factories, connections, pending calls, and dynamic modules.

```
application
|
+-- Get<IOrders>() -> orders slot -> SharpLinkClient -> endpoint topology
+-- Get<IPayment>() -> payments slot -> SharpLinkClient -> endpoint topology
```

The coordinator stores immutable `FrozenDictionary` snapshots. `Get<T>` does one type lookup and calls the selected child `Get<T>`. The generated proxy retains that child channel, so an RPC call does not read a route table, parse a key, allocate a routing object, use `AsyncLocal`, or add a field to Protocol v2 frames.

## Static routes

The generator emits `ISharpLinkGeneratedClusterRouteManifest` from assembly-level `SharpLinkClusterContractAssembly` attributes. Its catalog keeps weak references and unregisters collectible AssemblyLoadContexts while unloading. At build time the coordinator validates limits, snapshots only declared routes, validates unique contract ownership, expands generated dependency closures inside the same slot, and passes the immutable manifest list to the child builder's internal build context. A dependency closure contributes its generated codecs and assembly identity, but its proxy descriptors are hidden unless that contract-owning assembly is explicitly routed to the slot.

An ordinary `SharpClientBuilder.Build()` remains unchanged and still snapshots the complete generated assembly catalog. A multi-cluster child never implicitly exposes unrelated process-wide manifests.

## Lifecycle and locking

`ConnectAsync` is shared and starts all required child slots with bounded parallelism. Initial failure cancels unscheduled work, stops every started child, and transitions the coordinator to `Faulted`. After a successful start, a non-ready child makes the aggregate state `Degraded`; routes remain pinned to their original slot and no fallback is attempted. `StopAsync` first transitions to `Draining`, then stops every child even when another child fails cleanup, releases route snapshots, and reaches `Stopped`.

Lock order is coordinator gate, then child gate. The coordinator never awaits while holding its gate, and child code never calls back into the coordinator while holding a child gate. Route reads are lock-free. User-provided factories, resolver, selector, admission, retry, interceptor, authenticator, and service-provider callbacks are not invoked while the coordinator gate is held.

## Dynamic assemblies

`RegisterAssembly(cluster, assembly)` validates the target slot and global route ownership, then delegates manifest, codec, dependency, and module validation to the selected child. Only after child registration succeeds does the coordinator publish one new route snapshot. A publication failure immediately starts a zero-wait child unregister.

Unregister removes new-proxy visibility from the coordinator before using the child's existing module drain. Existing proxies retain their child module lease semantics. Replacement is limited to the same slot and identical `ContractId` sets; new `Get<T>` calls observe the child replacement after it is published. Dynamic ownership is retained until the child reports that framework references have been released, preserving collectible ALC behavior.

## AOT and observability

Static routing uses generated manifests and module initializers only. It does not scan loaded assemblies. Dynamic registration retains the established structured failure on platforms where it is unsupported. Cluster names are not added to default per-call metrics.
38 changes: 38 additions & 0 deletions doc/migration-0.7.10.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Migrating to SharpLink 0.7.10

## Existing clients

No migration is needed for `SharpClientBuilder`, `ISharpLinkClient`, endpoint clusters, Protocol v2, or existing generated contract assemblies. A normal client preserves its existing manifest discovery and fixed-endpoint fast path.

## Static multi-cluster clients

1. Keep each reusable contract package independent of deployment topology.
2. In the application or hosting assembly, declare one route for each contract-owning assembly:

```csharp
[assembly: SharpLinkClusterContractAssembly("orders", typeof(OrderContractsMarker))]
[assembly: SharpLinkClusterContractAssembly("payments", typeof(PaymentContractsMarker))]
```

3. Configure matching slots with `SharpLinkMultiClusterClientBuilder`.
4. Call `Get<TContract>()` without a cluster parameter.

The generator rejects a malformed key or one contract assembly assigned to two clusters. Routes whose slots are not configured on a coordinator are ignored. For configured slots, Build rejects duplicate ContractType/ContractId ownership, missing generated dependencies, an empty slot without explicit dynamic opt-in, or a connection budget overrun.

## Runtime plugins

Use the explicit-slot APIs:

```csharp
client.RegisterAssembly("plugins", pluginAssembly);
await client.UnregisterAssemblyAsync("plugins", pluginAssembly, TimeSpan.FromSeconds(10));
await client.ReplaceAssemblyAsync("plugins", oldAssembly, newAssembly, TimeSpan.FromSeconds(10));
```

Contract-owning assemblies can belong to exactly one slot. Dependency-only assemblies may be owned by several slots, but their generated dependencies must already be present in the same child slot. Replacement cannot add, remove, or move contracts; use unregister followed by register when the contract set changes.

## Intentional limits

0.7.10 has no `Get<T>(cluster)`, default cluster, cross-cluster retry/failover, runtime route move, cluster list discovery, or cluster metadata in requests. Every configured slot participates in `ConnectAsync`; a plugin-only slot must set `AllowDynamicContracts = true`.

For hosted applications use `services.AddSharpLinkMultiClusterClient(...)` and obtain only `ISharpLinkMultiClusterClientAccessor`. Child clients are intentionally not registered in DI.
35 changes: 35 additions & 0 deletions doc/performance-0.7.10.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# SharpLink 0.7.10 performance evidence

## Scope

The coordinator is constructed only by `SharpLinkMultiClusterClientBuilder`; normal `SharpClientBuilder` construction and all single-client RPC invocation code are unchanged. The multi-cluster read path is limited to proxy construction. Calls made through an existing proxy use the identical child `IRpcChannel` path.

## Reproducible gate

Baseline is the implementation branch point `bfb26f0ba40c980a59e621d0a549399d7c444fba` (0.7.9). Candidate code is `08f83c1e76b4e5f4a9354527bb2d8d0a10100572`. Use Release builds with no debugger or profiler, an unchanged power mode, identical concurrency and payloads, and alternate baseline and candidate for five warmed runs.

```
dotnet build Sharplink.slnx -c Release
dotnet run --project test/SharpLink.Benchmarks/SharpLink.Benchmarks.csproj -c Release -- --filter *UnaryBenchmarks.Rpc_Add*
```

Record raw QPS, P50, P99, error count, process allocations, and BenchmarkDotNet bytes/op for every run. P1 passes only when ordinary fixed TCP QPS median is at least 99% of baseline, P99 is at most 105%, and bytes/op do not increase. Repeat for UDS, Named Pipe and Shared Memory where supported, at c1/c8/c32/c128.

P2 compares a direct child proxy with a proxy acquired once from the multi-cluster coordinator; the measurement starts after `Get<T>`. It requires QPS >=99%, P99 <=105%, identical bytes/op, and a route lookup counter that does not increase during calls. P3 runs resolver or dynamic-registration writes in slot A while slot B invokes a static unary method; slot B requires QPS >=97%, P99 <=105%, and zero unexpected failures. P4 records build/connect/stop time, configured and actual connection count, and coordinator memory for 2, 8, and 16 slots.

## Local P1 sample

The following alternating runs used macOS 26.4.1 arm64, .NET 10.0.2, 10 logical processors, Server GC disabled, fixed TCP `add`, c32, 2-second warmup, and 5-second measurement. Every run completed with zero errors. Raw JSON is retained locally under `artifacts/performance/issue-10/p1/`.

| round | baseline QPS | candidate QPS | baseline P99 (us) | candidate P99 (us) |
| --- | ---: | ---: | ---: | ---: |
| 1 | 80,621 | 54,618 | 7,561 | 9,229 |
| 2 | 419,848 | 421,875 | 137 | 130 |
| 3 | 366,690 | 187,101 | 169 | 1,585 |
| 4 | 413,036 | 418,630 | 138 | 137 |
| 5 | 486,636 | 402,208 | 121 | 146 |
| median | 413,036 | 402,208 | 138 | 146 |

The local machine showed substantial short-run scheduling variation. The observed medians are 97.38% QPS and 105.80% P99 relative to baseline, so this sample does **not** clear the strict P1 release gate. It is diagnostic evidence only, not a release approval.

P1 still needs the full transport/concurrency matrix and BenchmarkDotNet allocation comparison on a stable release host. P2-P4 need their corresponding direct-child comparison, cross-slot writer-pressure, and 2/8/16-slot resource runs before a release can claim all Issue 10 performance gates. Functional Release build, Unit, Generator, Integration, PackageSmoke, 120-second Chaos, and NativeAOT smoke are verified separately.
48 changes: 48 additions & 0 deletions src/SharpLink.Abstractions/ISharpLinkMultiClusterClient.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
using System.Reflection;

namespace SharpLink.Abstractions;

/// <summary>
/// Coordinates isolated SharpLink clients and routes a contract once while its proxy is created.
/// Subsequent RPC calls execute directly through the selected child client.
/// </summary>
public interface ISharpLinkMultiClusterClient : IAsyncDisposable
{
/// <summary>Gets the aggregate coordinator lifecycle state.</summary>
SharpLinkMultiClusterState State { get; }

/// <summary>Connects every required cluster slot.</summary>
ValueTask ConnectAsync(CancellationToken cancellationToken = default);

/// <summary>Stops every cluster slot and releases coordinator-owned state.</summary>
ValueTask StopAsync(CancellationToken cancellationToken = default);

/// <summary>Creates a proxy by looking up the contract's cluster route exactly once.</summary>
TContract Get<TContract>() where TContract : IService;

/// <summary>Gets the lifecycle state of one configured cluster slot.</summary>
SharpLinkConnectionState GetClusterState(SharpLinkClusterKey cluster);

/// <summary>Runs a health check only against the specified cluster slot.</summary>
ValueTask<SharpLinkHealthCheckResult> CheckHealthAsync(
SharpLinkClusterKey cluster,
CancellationToken cancellationToken = default);

/// <summary>Registers an assembly's generated artifacts in the specified cluster slot.</summary>
SharpLinkAssemblyRegistrationResult RegisterAssembly(SharpLinkClusterKey cluster, Assembly assembly);

/// <summary>Drains and unregisters an assembly from its specified cluster slot.</summary>
ValueTask<SharpLinkAssemblyUnregisterResult> UnregisterAssemblyAsync(
SharpLinkClusterKey cluster,
Assembly assembly,
TimeSpan gracefulTimeout,
CancellationToken cancellationToken = default);

/// <summary>Replaces an assembly registration inside its specified cluster slot.</summary>
ValueTask<SharpLinkAssemblyReplacementResult> ReplaceAssemblyAsync(
SharpLinkClusterKey cluster,
Assembly oldAssembly,
Assembly newAssembly,
TimeSpan gracefulTimeout,
CancellationToken cancellationToken = default);
}
47 changes: 47 additions & 0 deletions src/SharpLink.Abstractions/SharpLinkClusterKey.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
namespace SharpLink.Abstractions;

/// <summary>Identifies one case-sensitive logical cluster in a multi-cluster client.</summary>
public readonly record struct SharpLinkClusterKey
{
/// <summary>Creates a validated cluster key.</summary>
/// <param name="value">A one to 64 character ASCII cluster key.</param>
public SharpLinkClusterKey(string value)
{
if (!IsValid(value))
{
throw new ArgumentException(
"Cluster keys must contain 1 to 64 ASCII characters, start with a letter or digit, and then contain only letters, digits, '.', '_', or '-'.",
nameof(value));
}

Value = value;
}

/// <summary>Gets the original, case-sensitive key value.</summary>
public string Value { get; }

/// <summary>Returns whether a value satisfies the cluster-key grammar without normalization.</summary>
public static bool IsValid(string? value)
{
if (string.IsNullOrEmpty(value) || value.Length > 64 || !IsAlphaNumeric(value[0]))
return false;

for (var index = 1; index < value.Length; index++)
{
var character = value[index];
if (!IsAlphaNumeric(character) && character is not '.' and not '_' and not '-')
return false;
}

return true;
}

/// <summary>Returns the original key value.</summary>
public override string ToString() => Value ?? string.Empty;

/// <summary>Creates a validated cluster key from a string.</summary>
public static implicit operator SharpLinkClusterKey(string value) => new(value);

private static bool IsAlphaNumeric(char value)
=> value is >= 'a' and <= 'z' or >= 'A' and <= 'Z' or >= '0' and <= '9';
}
Loading
Loading