feat: wire remaining E2E gaps — account imports, subject transforms, JWT auth, service latency

Close all 5 server-side wiring gaps so E2E tests pass without skips:
- System events: bridge user-defined system_account to internal $SYS
- Account imports/exports: config parsing + reverse response import for cross-account request-reply
- Subject transforms: parse mappings config block, apply in ProcessMessage
- JWT auth: parse trusted_keys, resolver MEMORY, resolver_preload in config
- Service latency: timestamp on request, publish ServiceLatencyMsg on response
This commit is contained in:
Joseph Doherty
2026-03-12 23:03:12 -04:00
parent 246fc7ad87
commit 95e9f0a92e
5 changed files with 571 additions and 16 deletions

View File

@@ -529,6 +529,17 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
_systemAccount = new Account(Account.SystemAccountName) { IsSystemAccount = true };
_accounts[Account.SystemAccountName] = _systemAccount;
// If a user-defined system_account is configured, promote that account to be the
// system account. Events published to $SYS.* will be delivered to subscribers on
// this account. Go reference: server/server.go — configureAccounts / setSystemAccount.
if (!string.IsNullOrEmpty(options.SystemAccount) &&
!string.Equals(options.SystemAccount, Account.SystemAccountName, StringComparison.OrdinalIgnoreCase))
{
var userSysAccount = GetOrCreateAccount(options.SystemAccount);
userSysAccount.IsSystemAccount = true;
_systemAccount = userSysAccount;
}
// Create system internal client and event system
var sysClientId = Interlocked.Increment(ref _nextClientId);
var sysClient = new InternalClient(sysClientId, ClientKind.System, _systemAccount);
@@ -1312,7 +1323,7 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
if (si.Invalid) continue;
if (SubjectMatch.MatchLiteral(subject, si.From))
{
ProcessServiceImport(si, subject, replyTo, headers, payload);
ProcessServiceImport(si, subject, replyTo, headers, payload, sender.Account);
delivered = true;
}
}
@@ -1453,7 +1464,7 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
/// Reference: Go server/accounts.go addServiceImport / processServiceImport.
/// </summary>
public void ProcessServiceImport(ServiceImport si, string subject, string? replyTo,
ReadOnlyMemory<byte> headers, ReadOnlyMemory<byte> payload)
ReadOnlyMemory<byte> headers, ReadOnlyMemory<byte> payload, Account? sourceAccount = null)
{
if (si.Invalid) return;
@@ -1477,6 +1488,24 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
targetSubject = MapImportSubject(subject, si.From, si.To);
}
// Set up a temporary reverse service import so that responses from the
// destination (exporter) account can route back to the source (importer)
// account. This handles request-reply across account boundaries.
// Go reference: client.go setupResponseServiceImport
if (replyTo != null && sourceAccount != null && !si.IsResponse)
{
SetupResponseServiceImport(si.DestinationAccount, sourceAccount, replyTo, si.Export);
}
// Service latency tracking: when the response arrives back, compute elapsed
// time and publish a latency metric to the configured subject.
// Go reference: client.go processServiceImport — latency tracking path.
if (si.IsResponse && si.Tracking && si.TimestampTicks > 0)
{
var elapsed = TimeSpan.FromTicks(Environment.TickCount64 * TimeSpan.TicksPerMillisecond - si.TimestampTicks);
PublishServiceLatency(si, elapsed);
}
// Match against destination account's SubList
var destSubList = si.DestinationAccount.SubList;
var result = destSubList.Match(targetSubject);
@@ -1498,6 +1527,36 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
}
}
/// <summary>
/// Creates a temporary reverse service import in the exporter's account so that
/// when the exporter publishes a response to the reply subject, the message is
/// forwarded back to the importer's account where the reply subscription lives.
/// Go reference: client.go setupResponseServiceImport.
/// </summary>
private static void SetupResponseServiceImport(Account exporterAccount, Account importerAccount, string replyTo, ServiceExport? export = null)
{
// Check if a reverse import for this reply subject already exists
if (exporterAccount.Imports.Services.ContainsKey(replyTo))
return;
// Determine if we should track latency for this response
var shouldTrack = export?.Latency is { } latency && LatencyTracker.ShouldSample(latency);
var reverseImport = new ServiceImport
{
DestinationAccount = importerAccount,
From = replyTo,
To = replyTo,
IsResponse = true,
UsePub = true,
Export = export,
Tracking = shouldTrack,
// Store start time as TickCount64 (milliseconds) converted to ticks for elapsed computation
TimestampTicks = shouldTrack ? Environment.TickCount64 * TimeSpan.TicksPerMillisecond : 0,
};
exporterAccount.Imports.AddServiceImport(reverseImport);
}
/// <summary>
/// Maps a published subject from the import "From" pattern to the "To" pattern.
/// For example, if From="requests.>" and To="api.>" and subject="requests.test",
@@ -1633,11 +1692,54 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
acc.MaxConnections = config.MaxConnections;
acc.MaxSubscriptions = config.MaxSubscriptions;
acc.DefaultPermissions = config.DefaultPermissions;
// Wire exports from config
if (config.Exports != null)
{
foreach (var export in config.Exports)
{
if (export.Service is { Length: > 0 } svc)
{
ServiceLatency? latency = export.LatencySubject is { Length: > 0 }
? new ServiceLatency { Subject = export.LatencySubject, SamplingPercentage = export.LatencySampling }
: null;
acc.AddServiceExport(svc, Imports.ServiceResponseType.Singleton, approved: null, latency: latency);
}
else if (export.Stream is { Length: > 0 } strm)
{
acc.AddStreamExport(strm, approved: null);
}
}
}
// Wire imports from config (deferred — needs destination accounts resolved)
if (config.Imports != null)
WireAccountImports(acc, config.Imports);
}
return acc;
});
}
private void WireAccountImports(Account importer, List<Auth.ImportDefinition> imports)
{
foreach (var imp in imports)
{
if (imp.ServiceAccount is { Length: > 0 } svcAcct && imp.ServiceSubject is { Length: > 0 } svcSubj)
{
var dest = GetOrCreateAccount(svcAcct);
var localSubject = imp.To ?? svcSubj;
importer.AddServiceImport(dest, from: localSubject, to: svcSubj);
}
else if (imp.StreamAccount is { Length: > 0 } strmAcct && imp.StreamSubject is { Length: > 0 } strmSubj)
{
var source = GetOrCreateAccount(strmAcct);
var localSubject = imp.To ?? strmSubj;
importer.AddStreamImport(source, from: strmSubj, to: localSubject);
}
}
}
/// <summary>
/// Returns true if the subject belongs to the $SYS subject space.
/// Reference: Go server/server.go — isReservedSubject.
@@ -1675,6 +1777,25 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
return account?.SubList ?? _globalAccount.SubList;
}
/// <summary>
/// Publishes a service latency metric message to the configured latency subject.
/// Go reference: client.go processServiceImport — trackLatency path.
/// </summary>
private void PublishServiceLatency(ServiceImport si, TimeSpan elapsed)
{
var latency = si.Export?.Latency;
if (latency == null || string.IsNullOrEmpty(latency.Subject))
return;
var msg = LatencyTracker.BuildLatencyMsg(
requestor: si.DestinationAccount.Name,
responder: si.Export?.Account?.Name ?? "unknown",
serviceLatency: elapsed,
totalLatency: elapsed);
SendInternalMsg(latency.Subject, reply: null, msg);
}
public void SendInternalMsg(string subject, string? reply, object? msg)
{
_eventSystem?.Enqueue(new PublishMessage { Subject = subject, Reply = reply, Body = msg });