Skip to content

Commit ff906b0

Browse files
ReubenBondCopilot
andauthored
Add IMembershipManager abstraction for external membership systems (#9960)
* Add IMembershipManager and IClusterHealthMonitor abstractions for external membership systems - Add IMembershipManager interface for native membership integration (e.g., RapidCluster) - Add IClusterHealthMonitor interface to abstract failure detection - Add NoOpClusterHealthMonitor for systems with external failure detection - Add NoOpMembershipTable as fallback when IMembershipManager is used - Update SiloClusteringValidator to accept IMembershipManager as alternative to IMembershipTable - Update ClusterMembershipService, MembershipAgent, and related services to use IMembershipManager when available - Update DefaultSiloServices to register fallback NoOpMembershipTable via TryAddSingleton These changes allow external consensus-based membership systems to integrate natively with Orleans without requiring a full IMembershipTable implementation. * Add IProbeHealthMonitor for pluggable probe health checks - Extract probe health checks from LocalSiloHealthMonitor into IProbeHealthMonitor interface - Add DefaultProbeHealthMonitor using ProbeRequestMonitor and ClusterHealthMonitor - Add NoOpProbeHealthMonitor for external failure detection systems - Register DefaultProbeHealthMonitor in DefaultSiloServices - Add InternalsVisibleTo for RapidCluster.Orleans assemblies This allows external membership systems like RapidCluster to disable Orleans' probe-based health checks that would otherwise report false degradation when using a different failure detection protocol. * Refactor membership service to use IMembershipManager interface Replace direct MembershipTableManager dependencies with the IMembershipManager abstraction across the runtime: - ManagementGrain - MembershipSystemTarget - LocalSiloHealthMonitor - SiloHealthMonitor - SiloMetadataCache - SiloStatusListenerManager - SiloRoleBasedPlacementDirector Also add optional targetVersion parameter to Refresh() for waiting until membership reaches a specific version. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address PR review comments on IMembershipManager abstraction - Remove unused 'using System.Threading.Tasks' in IClusterHealthMonitor.cs - Add CancellationToken to IMembershipManager.Refresh to prevent infinite loops - Fix nullable annotation: TrySuspectSilo uses SiloAddress? instead of null! - Remove InternalsVisibleTo for RapidCluster.* from Runtime and Core csproj - Use monitoredNodeCount parameter in DefaultProbeHealthMonitor - Rename constructor parameter in SiloStatusListenerManager to match type - Fix SiloClusteringValidator to detect NoOpMembershipTable - Change SiloRoleBasedPlacementDirector to inject IMembershipManager Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Remove NoOpMembershipTable; register MembershipTableManager through IMembershipManager When a custom IMembershipManager is registered, MembershipTableManager is no longer instantiated. The NoOpMembershipTable fallback is no longer needed. - Delete NoOpMembershipTable.cs - Make IMembershipManager extend ILifecycleParticipant and IHealthCheckParticipant (same pattern as IClusterHealthMonitor) - Register MembershipTableManager via TryAddSingleton<IMembershipManager> so custom implementations take precedence - Resolve lifecycle/health participants from IMembershipManager interface - Update SiloClusteringValidator to detect missing clustering config without depending on NoOpMembershipTable Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix nullable annotation in SiloClusteringValidator Change IMembershipManager to IMembershipManager? to fix CS8600 error when assigning null to a non-nullable reference type. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Remove all RapidCluster references from doc comments Replace product-specific references with generic 'external system' phrasing in IClusterHealthMonitor, IProbeHealthMonitor, NoOpClusterHealthMonitor, and NoOpProbeHealthMonitor. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix nullable annotation on TrySuspectSilo explicit implementation Match the IMembershipManager interface's SiloAddress? annotation on the indirectProbingSilo parameter. The file uses #nullable disable, so add a localized #nullable enable/disable around the line. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Change IClusterHealthMonitor.SiloMonitors to IReadOnlyDictionary Use the more general IReadOnlyDictionary<SiloAddress, SiloHealthMonitor> instead of ImmutableDictionary so implementations are not forced to use a specific collection type. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add CancellationToken to all IMembershipManager Task methods Add a required (no default value) CancellationToken parameter to every Task-returning method on IMembershipManager: - UpdateLocalStatus - TryKillSilo - TrySuspectSilo - Refresh (remove existing defaults) - ProcessGossipSnapshot - UpdateIAmAlive Update the MembershipTableManager explicit implementations and all call sites to pass an appropriate token (shutdown token, caller's token, or CancellationToken.None where none is available). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix ClusterHealthMonitor not participating in silo lifecycle TryAddFromExisting skips when ANY registration for the service type already exists. Since ILifecycleParticipant<ISiloLifecycle> and IHealthCheckParticipant have many prior registrations, the forwarding entries for IClusterHealthMonitor were silently dropped. This meant ClusterHealthMonitor never started its probe loop, breaking failure detection in longer-running tests (Azure Storage provider tests). Change TryAddFromExisting to AddFromExisting so the forwarding registrations are always added. The TryAddSingleton on the IClusterHealthMonitor registration itself already handles the replacement scenario for external membership systems. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Rename DefaultProbeHealthMonitor to ProbingSiloHealthMonitor; fix threshold - Rename DefaultProbeHealthMonitor to ProbingSiloHealthMonitor to preserve the log category closer to the original LocalSiloHealthMonitor. - Fix CheckReceivedProbeResponses to use siloMonitors.Count for the threshold instead of the caller-provided monitoredNodeCount parameter, matching the original behavior that used the actual expander-graph monitor count. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 69c28aa commit ff906b0

21 files changed

+609
-181
lines changed

src/Orleans.Core/Orleans.Core.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
<OrleansBuildTimeCodeGen>true</OrleansBuildTimeCodeGen>
99
<PackageReadmeFile>README.md</PackageReadmeFile>
1010
</PropertyGroup>
11+
1112
<ItemGroup>
1213
<Content Include="buildMultiTargeting\Microsoft.Orleans.Core.targets">
1314
<PackagePath>%(Identity)</PackagePath>

src/Orleans.Runtime/Configuration/Validators/SiloClusteringValidator.cs

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using Microsoft.Extensions.Options;
55
using Orleans.Configuration;
66
using Orleans.Configuration.Validators;
7+
using Orleans.Runtime.MembershipService;
78

89
namespace Orleans.Runtime.Configuration
910
{
@@ -23,9 +24,25 @@ public SiloClusteringValidator(IServiceProvider serviceProvider)
2324
public void ValidateConfiguration()
2425
{
2526
var clusteringTableProvider = this.serviceProvider.GetService<IMembershipTable>();
26-
if (clusteringTableProvider == null)
27+
28+
if (clusteringTableProvider is null)
2729
{
28-
throw new OrleansConfigurationException(ClientClusteringValidator.ClusteringNotConfigured);
30+
// No IMembershipTable configured. A custom IMembershipManager must be present
31+
// (MembershipTableManager requires IMembershipTable, so it cannot be used).
32+
IMembershipManager? membershipManager = null;
33+
try
34+
{
35+
membershipManager = this.serviceProvider.GetService<IMembershipManager>();
36+
}
37+
catch
38+
{
39+
// Resolution failed — MembershipTableManager requires IMembershipTable.
40+
}
41+
42+
if (membershipManager is null or MembershipTableManager)
43+
{
44+
throw new OrleansConfigurationException(ClientClusteringValidator.ClusteringNotConfigured);
45+
}
2946
}
3047

3148
var clusterMembershipOptions = this.serviceProvider.GetRequiredService<IOptions<ClusterMembershipOptions>>().Value;
@@ -43,4 +60,4 @@ public void ValidateConfiguration()
4360
}
4461
}
4562
}
46-
}
63+
}

src/Orleans.Runtime/Core/ManagementGrain.cs

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using System;
22
using System.Collections.Generic;
33
using System.Linq;
4+
using System.Threading;
45
using System.Threading.Tasks;
56
using Microsoft.Extensions.Logging;
67
using Orleans.Concurrency;
@@ -25,7 +26,7 @@ internal partial class ManagementGrain : Grain, IManagementGrain
2526
private readonly IInternalGrainFactory internalGrainFactory;
2627
private readonly ISiloStatusOracle siloStatusOracle;
2728
private readonly IVersionStore versionStore;
28-
private readonly MembershipTableManager membershipTableManager;
29+
private readonly IMembershipManager membershipManager;
2930
private readonly GrainManifest siloManifest;
3031
private readonly ClusterManifest clusterManifest;
3132
private readonly ILogger logger;
@@ -37,12 +38,12 @@ public ManagementGrain(
3738
ISiloStatusOracle siloStatusOracle,
3839
IVersionStore versionStore,
3940
ILogger<ManagementGrain> logger,
40-
MembershipTableManager membershipTableManager,
41+
IMembershipManager membershipManager,
4142
IClusterManifestProvider clusterManifestProvider,
4243
Catalog catalog,
4344
GrainLocator grainLocator)
4445
{
45-
this.membershipTableManager = membershipTableManager;
46+
this.membershipManager = membershipManager;
4647
this.siloManifest = clusterManifestProvider.LocalGrainManifest;
4748
this.clusterManifest = clusterManifestProvider.Current;
4849
this.internalGrainFactory = internalGrainFactory;
@@ -55,15 +56,15 @@ public ManagementGrain(
5556

5657
public async Task<Dictionary<SiloAddress, SiloStatus>> GetHosts(bool onlyActive = false)
5758
{
58-
await this.membershipTableManager.Refresh();
59+
await this.membershipManager.Refresh(null, CancellationToken.None);
5960
return this.siloStatusOracle.GetApproximateSiloStatuses(onlyActive);
6061
}
6162

6263
public async Task<MembershipEntry[]> GetDetailedHosts(bool onlyActive = false)
6364
{
64-
await this.membershipTableManager.Refresh();
65+
await this.membershipManager.Refresh(null, CancellationToken.None);
6566

66-
var table = this.membershipTableManager.MembershipTableSnapshot;
67+
var table = this.membershipManager.CurrentSnapshot;
6768

6869
MembershipEntry[] result;
6970
if (onlyActive)

src/Orleans.Runtime/Hosting/DefaultSiloServices.cs

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -162,20 +162,22 @@ internal static void AddDefaultServices(ISiloBuilder builder)
162162
services.AddFromExisting<ILifecycleParticipant<ISiloLifecycle>, DeploymentLoadPublisher>();
163163

164164
services.AddSingleton<IAsyncTimerFactory, AsyncTimerFactory>();
165-
services.AddSingleton<MembershipTableManager>();
166-
services.AddFromExisting<IHealthCheckParticipant, MembershipTableManager>();
167-
services.AddFromExisting<ILifecycleParticipant<ISiloLifecycle>, MembershipTableManager>();
165+
166+
services.TryAddSingleton<IMembershipManager, MembershipTableManager>();
167+
services.AddFromExisting<IHealthCheckParticipant, IMembershipManager>();
168+
services.AddFromExisting<ILifecycleParticipant<ISiloLifecycle>, IMembershipManager>();
168169
services.AddSingleton<MembershipSystemTarget>();
169170
services.AddFromExisting<IMembershipService, MembershipSystemTarget>();
170171
services.AddFromExisting<ILifecycleParticipant<ISiloLifecycle>, MembershipSystemTarget>();
171172
services.AddSingleton<IMembershipGossiper, MembershipGossiper>();
172173
services.AddSingleton<IRemoteSiloProber, RemoteSiloProber>();
173174
services.AddSingleton<SiloStatusOracle>();
174175
services.TryAddFromExisting<ISiloStatusOracle, SiloStatusOracle>();
175-
services.AddSingleton<ClusterHealthMonitor>();
176-
services.AddFromExisting<ILifecycleParticipant<ISiloLifecycle>, ClusterHealthMonitor>();
177-
services.AddFromExisting<IHealthCheckParticipant, ClusterHealthMonitor>();
176+
services.TryAddSingleton<IClusterHealthMonitor, ClusterHealthMonitor>();
177+
services.AddFromExisting<ILifecycleParticipant<ISiloLifecycle>, IClusterHealthMonitor>();
178+
services.AddFromExisting<IHealthCheckParticipant, IClusterHealthMonitor>();
178179
services.AddSingleton<ProbeRequestMonitor>();
180+
services.TryAddSingleton<IProbeHealthMonitor, ProbingSiloHealthMonitor>();
179181
services.AddSingleton<LocalSiloHealthMonitor>();
180182
services.AddFromExisting<ILocalSiloHealthMonitor, LocalSiloHealthMonitor>();
181183
services.AddFromExisting<ILifecycleParticipant<ISiloLifecycle>, LocalSiloHealthMonitor>();
@@ -535,7 +537,7 @@ private class AllowOrleansTypes : ITypeNameFilter
535537
{
536538
public bool? IsTypeNameAllowed(string typeName, string assemblyName)
537539
{
538-
if (assemblyName is { Length: > 0} && assemblyName.Contains("Orleans"))
540+
if (assemblyName is { Length: > 0 } && assemblyName.Contains("Orleans"))
539541
{
540542
return true;
541543
}

src/Orleans.Runtime/MembershipService/ClusterHealthMonitor.cs

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,14 @@ namespace Orleans.Runtime.MembershipService
1818
{
1919
/// <summary>
2020
/// Responsible for ensuring that this silo monitors other silos in the cluster.
21+
/// This is the default, probe-based implementation of <see cref="IClusterHealthMonitor"/>.
2122
/// </summary>
22-
internal partial class ClusterHealthMonitor : IHealthCheckParticipant, ILifecycleParticipant<ISiloLifecycle>, ClusterHealthMonitor.ITestAccessor, IDisposable, IAsyncDisposable
23+
internal partial class ClusterHealthMonitor : IClusterHealthMonitor, ClusterHealthMonitor.ITestAccessor, IDisposable, IAsyncDisposable
2324
{
2425
private readonly CancellationTokenSource shutdownCancellation = new CancellationTokenSource();
2526
private readonly ILocalSiloDetails localSiloDetails;
2627
private readonly IServiceProvider serviceProvider;
27-
private readonly MembershipTableManager membershipService;
28+
private readonly IMembershipManager membershipManager;
2829
private readonly ILogger<ClusterHealthMonitor> log;
2930
private readonly IFatalErrorHandler fatalErrorHandler;
3031
private readonly IOptionsMonitor<ClusterMembershipOptions> clusterMembershipOptions;
@@ -46,15 +47,15 @@ internal interface ITestAccessor
4647

4748
public ClusterHealthMonitor(
4849
ILocalSiloDetails localSiloDetails,
49-
MembershipTableManager membershipService,
50+
IMembershipManager membershipManager,
5051
ILogger<ClusterHealthMonitor> log,
5152
IOptionsMonitor<ClusterMembershipOptions> clusterMembershipOptions,
5253
IFatalErrorHandler fatalErrorHandler,
5354
IServiceProvider serviceProvider)
5455
{
5556
this.localSiloDetails = localSiloDetails;
5657
this.serviceProvider = serviceProvider;
57-
this.membershipService = membershipService;
58+
this.membershipManager = membershipManager;
5859
this.log = log;
5960
this.fatalErrorHandler = fatalErrorHandler;
6061
this.clusterMembershipOptions = clusterMembershipOptions;
@@ -71,14 +72,14 @@ public ClusterHealthMonitor(
7172
/// <summary>
7273
/// Gets the collection of monitored silos.
7374
/// </summary>
74-
public ImmutableDictionary<SiloAddress, SiloHealthMonitor> SiloMonitors => this.monitoredSilos;
75+
public IReadOnlyDictionary<SiloAddress, SiloHealthMonitor> SiloMonitors => this.monitoredSilos;
7576

7677
private async Task ProcessMembershipUpdates()
7778
{
7879
try
7980
{
8081
LogDebugStartingToProcessMembershipUpdates(log);
81-
await foreach (var tableSnapshot in this.membershipService.MembershipTableUpdates.WithCancellation(this.shutdownCancellation.Token))
82+
await foreach (var tableSnapshot in this.membershipManager.MembershipUpdates.WithCancellation(this.shutdownCancellation.Token))
8283
{
8384
var utcNow = DateTime.UtcNow;
8485

@@ -131,9 +132,9 @@ private async Task EvictStaleStateSilos(
131132
try
132133
{
133134
LogDebugStaleSiloFound(log);
134-
await this.membershipService.TryToSuspectOrKill(member.Key);
135+
await this.membershipManager.TrySuspectSilo(member.Key, null, this.shutdownCancellation.Token);
135136
}
136-
catch(Exception exception)
137+
catch (Exception exception)
137138
{
138139
LogErrorTryToSuspectOrKillFailed(log, exception, member.Value.SiloAddress, member.Value.Status);
139140
}
@@ -316,12 +317,12 @@ private async Task OnProbeResultInternal(SiloHealthMonitor monitor, ProbeResult
316317
{
317318
if (probeResult.Status == ProbeResultStatus.Failed && probeResult.FailedProbeCount >= this.clusterMembershipOptions.CurrentValue.NumMissedProbesLimit)
318319
{
319-
await this.membershipService.TryToSuspectOrKill(monitor.TargetSiloAddress).ConfigureAwait(false);
320+
await this.membershipManager.TrySuspectSilo(monitor.TargetSiloAddress, null, this.shutdownCancellation.Token).ConfigureAwait(false);
320321
}
321322
}
322323
else if (probeResult.Status == ProbeResultStatus.Failed)
323324
{
324-
await this.membershipService.TryToSuspectOrKill(monitor.TargetSiloAddress, probeResult.Intermediary).ConfigureAwait(false);
325+
await this.membershipManager.TrySuspectSilo(monitor.TargetSiloAddress, probeResult.Intermediary, this.shutdownCancellation.Token).ConfigureAwait(false);
325326
}
326327
}
327328

src/Orleans.Runtime/MembershipService/ClusterMembershipService.cs

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,22 +13,22 @@ namespace Orleans.Runtime
1313
internal partial class ClusterMembershipService : IClusterMembershipService, ILifecycleParticipant<ISiloLifecycle>, IDisposable
1414
{
1515
private readonly AsyncEnumerable<ClusterMembershipSnapshot> updates;
16-
private readonly MembershipTableManager membershipTableManager;
16+
private readonly IMembershipManager membershipManager;
1717
private readonly ILogger<ClusterMembershipService> log;
1818
private readonly IFatalErrorHandler fatalErrorHandler;
1919
private ClusterMembershipSnapshot snapshot;
2020

2121
public ClusterMembershipService(
22-
MembershipTableManager membershipTableManager,
22+
IMembershipManager membershipManager,
2323
ILogger<ClusterMembershipService> log,
2424
IFatalErrorHandler fatalErrorHandler)
2525
{
26-
this.snapshot = membershipTableManager.MembershipTableSnapshot.CreateClusterMembershipSnapshot();
26+
this.snapshot = membershipManager.CurrentSnapshot.CreateClusterMembershipSnapshot();
2727
this.updates = new AsyncEnumerable<ClusterMembershipSnapshot>(
2828
initialValue: this.snapshot,
2929
updateValidator: (previous, proposed) => proposed.Version > previous.Version,
3030
onPublished: update => Interlocked.Exchange(ref this.snapshot, update));
31-
this.membershipTableManager = membershipTableManager;
31+
this.membershipManager = membershipManager;
3232
this.log = log;
3333
this.fatalErrorHandler = fatalErrorHandler;
3434
}
@@ -37,7 +37,7 @@ public ClusterMembershipSnapshot CurrentSnapshot
3737
{
3838
get
3939
{
40-
var tableSnapshot = this.membershipTableManager.MembershipTableSnapshot;
40+
var tableSnapshot = this.membershipManager.CurrentSnapshot;
4141
if (this.snapshot.Version == tableSnapshot.Version)
4242
{
4343
return this.snapshot;
@@ -64,25 +64,25 @@ async ValueTask RefreshAsync(MembershipVersion v, CancellationToken cancellation
6464
do
6565
{
6666
cancellationToken.ThrowIfCancellationRequested();
67-
if (!didRefresh || this.membershipTableManager.MembershipTableSnapshot.Version < v)
67+
if (!didRefresh || this.membershipManager.CurrentSnapshot.Version < v)
6868
{
69-
await this.membershipTableManager.Refresh();
69+
await this.membershipManager.Refresh(null, cancellationToken);
7070
didRefresh = true;
7171
}
7272

7373
await Task.Delay(TimeSpan.FromMilliseconds(10), cancellationToken);
74-
} while (this.snapshot.Version < v || this.snapshot.Version < this.membershipTableManager.MembershipTableSnapshot.Version);
74+
} while (this.snapshot.Version < v || this.snapshot.Version < this.membershipManager.CurrentSnapshot.Version);
7575
}
7676
}
7777

78-
public async Task<bool> TryKill(SiloAddress siloAddress) => await this.membershipTableManager.TryKill(siloAddress);
78+
public async Task<bool> TryKill(SiloAddress siloAddress) => await this.membershipManager.TryKillSilo(siloAddress, CancellationToken.None);
7979

8080
private async Task ProcessMembershipUpdates(CancellationToken ct)
8181
{
8282
try
8383
{
8484
LogDebugStartingToProcessMembershipUpdates(log);
85-
await foreach (var tableSnapshot in this.membershipTableManager.MembershipTableUpdates.WithCancellation(ct))
85+
await foreach (var tableSnapshot in this.membershipManager.MembershipUpdates.WithCancellation(ct))
8686
{
8787
this.updates.TryPublish(tableSnapshot.CreateClusterMembershipSnapshot());
8888
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
#nullable enable
2+
3+
using System.Collections.Generic;
4+
5+
namespace Orleans.Runtime.MembershipService;
6+
7+
/// <summary>
8+
/// Responsible for monitoring the health of silos in the cluster and detecting failures.
9+
/// </summary>
10+
/// <remarks>
11+
/// <para>
12+
/// Orleans supports two failure detection models:
13+
/// </para>
14+
/// <list type="number">
15+
/// <item>
16+
/// <description>
17+
/// <b>Built-in probe-based detection</b> (default): Orleans silos actively probe each other
18+
/// using an expander graph topology. When a silo misses too many probes, it is suspected
19+
/// and eventually declared dead. This is implemented by <see cref="ClusterHealthMonitor"/>.
20+
/// </description>
21+
/// </item>
22+
/// <item>
23+
/// <description>
24+
/// <b>External failure detection</b>: An external membership system
25+
/// handles failure detection through its own protocol. In this case, register a no-op
26+
/// implementation to disable Orleans' built-in probing while allowing the external system
27+
/// to manage membership changes through <see cref="IMembershipManager"/>.
28+
/// </description>
29+
/// </item>
30+
/// </list>
31+
/// <para>
32+
/// When using external failure detection, liveness IS still enabled - it's just handled by
33+
/// the external system rather than Orleans' probe-based mechanism. This is different from
34+
/// <c>ClusterMembershipOptions.LivenessEnabled = false</c>, which was a workaround that
35+
/// disabled the DeclareDead step without properly disabling the probing infrastructure.
36+
/// </para>
37+
/// </remarks>
38+
internal interface IClusterHealthMonitor : ILifecycleParticipant<ISiloLifecycle>, IHealthCheckParticipant
39+
{
40+
/// <summary>
41+
/// Gets the collection of silos currently being monitored by this silo.
42+
/// </summary>
43+
/// <remarks>
44+
/// For external failure detection systems, this returns an empty dictionary since
45+
/// monitoring is handled externally.
46+
/// </remarks>
47+
IReadOnlyDictionary<SiloAddress, SiloHealthMonitor> SiloMonitors { get; }
48+
}

0 commit comments

Comments
 (0)