feat: add stream import cycle detection (Gap 9.3)

Add StreamImportFormsCycle DFS method to Account plus GetStreamImportSources
and HasStreamImportFrom helpers. Add GetStreamImportSourceAccounts to ImportMap.
10 tests cover direct, indirect, self, diamond, and empty import scenarios.
This commit is contained in:
Joseph Doherty
2026-02-25 12:53:04 -05:00
parent 3107615885
commit 2bdf0e75ed
5 changed files with 436 additions and 0 deletions

View File

@@ -212,6 +212,65 @@ public sealed class Account : IDisposable
/// <summary>Records a service request latency sample on this account's tracker.</summary>
public void RecordServiceLatency(double latencyMs) => LatencyTracker.RecordLatency(latencyMs);
/// <summary>
/// Returns all service exports registered on this account.
/// Go reference: accounts.go exports.services iteration.
/// </summary>
public IReadOnlyList<ServiceExportInfo> GetAllServiceExports()
{
var result = new List<ServiceExportInfo>(Exports.Services.Count);
foreach (var (subject, se) in Exports.Services)
result.Add(ToServiceExportInfo(subject, se));
return result;
}
/// <summary>
/// Returns the service export for an exact subject match, or null if not found.
/// Does not apply wildcard matching.
/// Go reference: accounts.go getServiceExport (direct map lookup only).
/// </summary>
public ServiceExportInfo? GetExactServiceExport(string subject)
{
if (Exports.Services.TryGetValue(subject, out var se))
return ToServiceExportInfo(subject, se);
return null;
}
/// <summary>
/// Finds a service export whose subject pattern matches the given subject using
/// wildcard matching. Returns null when no export pattern matches.
/// Go reference: accounts.go getWildcardServiceExport (line 2849).
/// </summary>
public ServiceExportInfo? GetWildcardServiceExport(string subject)
{
// First try exact match
if (Exports.Services.TryGetValue(subject, out var exact))
return ToServiceExportInfo(subject, exact);
// Then scan for a wildcard pattern that matches
foreach (var (pattern, se) in Exports.Services)
{
if (SubjectMatch.MatchLiteral(subject, pattern))
return ToServiceExportInfo(pattern, se);
}
return null;
}
/// <summary>
/// Returns true when any service export (exact or wildcard) matches the given subject.
/// Go reference: accounts.go getServiceExport.
/// </summary>
public bool HasServiceExport(string subject) => GetWildcardServiceExport(subject) != null;
private static ServiceExportInfo ToServiceExportInfo(string subject, ServiceExport se)
{
IReadOnlyList<string> approved = se.Auth.ApprovedAccounts != null
? [.. se.Auth.ApprovedAccounts]
: [];
bool isWildcard = subject.Contains('*') || subject.Contains('>');
return new ServiceExportInfo(subject, se.ResponseType, approved, isWildcard);
}
public void AddServiceExport(string subject, ServiceResponseType responseType, IEnumerable<Account>? approved)
{
var auth = new ExportAuth

View File

@@ -1,3 +1,5 @@
using NATS.Server.Auth;
namespace NATS.Server.Imports;
public sealed class ImportMap
@@ -15,4 +17,23 @@ public sealed class ImportMap
list.Add(si);
}
/// <summary>
/// Returns the distinct set of source accounts referenced by stream imports.
/// Go reference: accounts.go imports.streams — each streamImport has an acc field.
/// </summary>
public IReadOnlyList<Account> GetStreamImportSourceAccounts()
{
if (Streams.Count == 0)
return [];
var seen = new HashSet<string>(StringComparer.Ordinal);
var result = new List<Account>(Streams.Count);
foreach (var si in Streams)
{
if (seen.Add(si.SourceAccount.Name))
result.Add(si.SourceAccount);
}
return result;
}
}

View File

@@ -0,0 +1,12 @@
namespace NATS.Server.Imports;
/// <summary>
/// Immutable view of a service export, returned by Account query methods.
/// IsWildcard is true when the subject contains '*' or '>'.
/// Go reference: accounts.go serviceExport struct.
/// </summary>
public sealed record ServiceExportInfo(
string Subject,
ServiceResponseType ResponseType,
IReadOnlyList<string> ApprovedAccounts,
bool IsWildcard);