From f4dfbf49bd8af8f11404aedb28e5f600acc14cd4 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 28 Feb 2026 20:10:06 -0500 Subject: [PATCH] feat(batch19): implement service import maps and response subscription methods --- .../ZB.MOM.NatsNet.Server/Accounts/Account.cs | 537 ++++++++++++++++++ .../ZB.MOM.NatsNet.Server/ClientConnection.cs | 12 + porting.db | Bin 6676480 -> 6684672 bytes 3 files changed, 549 insertions(+) diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/Accounts/Account.cs b/dotnet/src/ZB.MOM.NatsNet.Server/Accounts/Account.cs index 3fe30ed..a6ef4db 100644 --- a/dotnet/src/ZB.MOM.NatsNet.Server/Accounts/Account.cs +++ b/dotnet/src/ZB.MOM.NatsNet.Server/Accounts/Account.cs @@ -2231,6 +2231,543 @@ public sealed class Account : INatsAccount } } + /// + /// Number of configured service-import subject keys. + /// Mirrors Go (a *Account) NumServiceImports() int. + /// + public int NumServiceImports() + { + _mu.EnterReadLock(); + try { return Imports.Services?.Count ?? 0; } + finally { _mu.ExitReadLock(); } + } + + /// + /// Removes a response service import and performs reverse-map cleanup. + /// Mirrors Go (a *Account) removeRespServiceImport(...). + /// + internal void RemoveRespServiceImport(ServiceImportEntry? serviceImport, RsiReason reason) + { + if (serviceImport == null) + return; + + Account? destination; + string from; + string to; + bool tracking; + bool delivered; + ClientConnection? requestor; + byte[]? sid; + + _mu.EnterWriteLock(); + try + { + if (Exports.Responses != null) + Exports.Responses.Remove(serviceImport.From); + + destination = serviceImport.Account; + from = serviceImport.From; + to = serviceImport.To; + tracking = serviceImport.Tracking; + delivered = serviceImport.DidDeliver; + requestor = serviceImport.RequestingClient; + sid = serviceImport.SubscriptionId; + } + finally + { + _mu.ExitWriteLock(); + } + + if (sid is { Length: > 0 } && InternalClient != null) + InternalClient.ProcessUnsub(sid); + + if (tracking && requestor != null && !delivered) + SendBackendErrorTrackingLatency(serviceImport, reason); + + destination?.CheckForReverseEntry(to, serviceImport, false); + } + + /// + /// Gets a service import for a specific destination account and subject key. + /// Lock must be held by caller. + /// Mirrors Go (a *Account) getServiceImportForAccountLocked(...). + /// + internal ServiceImportEntry? GetServiceImportForAccountLocked(string destinationAccountName, string subject) + { + if (Imports.Services == null || !Imports.Services.TryGetValue(subject, out var serviceImports)) + return null; + + if (serviceImports.Count == 1 && serviceImports[0].Account?.Name == destinationAccountName) + return serviceImports[0]; + + foreach (var serviceImport in serviceImports) + { + if (serviceImport.Account?.Name == destinationAccountName) + return serviceImport; + } + + return null; + } + + /// + /// Removes a service import mapping by destination account name and subject key. + /// Mirrors Go (a *Account) removeServiceImport(dstAccName, subject string). + /// + internal void RemoveServiceImport(string destinationAccountName, string subject) + { + ServiceImportEntry? removed = null; + byte[]? sid = null; + + _mu.EnterWriteLock(); + try + { + if (Imports.Services == null || !Imports.Services.TryGetValue(subject, out var serviceImports)) + return; + + if (serviceImports.Count == 1) + { + if (serviceImports[0].Account?.Name == destinationAccountName) + { + removed = serviceImports[0]; + Imports.Services.Remove(subject); + } + } + else + { + for (var i = 0; i < serviceImports.Count; i++) + { + if (serviceImports[i].Account?.Name == destinationAccountName) + { + removed = serviceImports[i]; + serviceImports.RemoveAt(i); + Imports.Services[subject] = serviceImports; + break; + } + } + } + + if (removed?.SubscriptionId is { Length: > 0 }) + sid = removed.SubscriptionId; + } + finally + { + _mu.ExitWriteLock(); + } + + if (sid != null && InternalClient != null) + InternalClient.ProcessUnsub(sid); + } + + /// + /// Adds an entry to the reverse-response map for response cleanup. + /// Mirrors Go (a *Account) addReverseRespMapEntry(...). + /// + internal void AddReverseRespMapEntry(Account account, string reply, string from) + { + _mu.EnterWriteLock(); + try + { + Imports.ReverseResponseMap ??= new Dictionary>(StringComparer.Ordinal); + if (!Imports.ReverseResponseMap.TryGetValue(reply, out var entries)) + { + entries = []; + Imports.ReverseResponseMap[reply] = entries; + } + + entries.Add(new ServiceRespEntry + { + Account = account, + MappedSubject = from, + }); + } + finally + { + _mu.ExitWriteLock(); + } + } + + /// + /// Checks reverse-response entries for wildcard replies. + /// Mirrors Go (a *Account) checkForReverseEntries(...). + /// + internal void CheckForReverseEntries(string reply, bool checkInterest, bool recursed) + { + if (!SubscriptionIndex.SubjectHasWildcard(reply)) + { + CheckForReverseEntry(reply, null, checkInterest, recursed); + return; + } + + List replies; + _mu.EnterReadLock(); + try + { + if (Imports.ReverseResponseMap == null || Imports.ReverseResponseMap.Count == 0) + return; + replies = [.. Imports.ReverseResponseMap.Keys]; + } + finally + { + _mu.ExitReadLock(); + } + + var replyTokens = SubjectTransform.TokenizeSubject(reply); + foreach (var candidate in replies) + { + if (SubjectTransform.IsSubsetMatch(SubjectTransform.TokenizeSubject(candidate), reply)) + CheckForReverseEntry(candidate, null, checkInterest, recursed); + else if (SubjectTransform.IsSubsetMatch(replyTokens, candidate)) + CheckForReverseEntry(candidate, null, checkInterest, recursed); + } + } + + /// + /// Checks and optionally removes reverse-response entries. + /// Mirrors Go (a *Account) checkForReverseEntry(...). + /// + internal void CheckForReverseEntry(string reply, ServiceImportEntry? serviceImport, bool checkInterest) => + CheckForReverseEntry(reply, serviceImport, checkInterest, false); + + /// + /// Internal reverse-entry checker with recursion protection. + /// Mirrors Go (a *Account) _checkForReverseEntry(...). + /// + internal void CheckForReverseEntry(string reply, ServiceImportEntry? serviceImport, bool checkInterest, bool recursed) + { + List? responseEntries; + + _mu.EnterReadLock(); + try + { + if (Imports.ReverseResponseMap == null || Imports.ReverseResponseMap.Count == 0) + return; + + if (SubscriptionIndex.SubjectHasWildcard(reply)) + { + if (recursed) + return; + } + else if (!Imports.ReverseResponseMap.TryGetValue(reply, out responseEntries) || responseEntries == null) + { + return; + } + else if (checkInterest && Sublist != null && Sublist.HasInterest(reply)) + { + return; + } + } + finally + { + _mu.ExitReadLock(); + } + + if (SubscriptionIndex.SubjectHasWildcard(reply)) + { + CheckForReverseEntries(reply, checkInterest, true); + return; + } + + _mu.EnterWriteLock(); + try + { + if (Imports.ReverseResponseMap == null || !Imports.ReverseResponseMap.TryGetValue(reply, out responseEntries) || responseEntries == null) + return; + + if (serviceImport == null) + { + Imports.ReverseResponseMap.Remove(reply); + } + else + { + responseEntries.RemoveAll(entry => entry.MappedSubject == serviceImport.From); + + if (responseEntries.Count == 0) + Imports.ReverseResponseMap.Remove(reply); + else + Imports.ReverseResponseMap[reply] = responseEntries; + } + } + finally + { + _mu.ExitWriteLock(); + } + } + + /// + /// Returns true when a service import is overshadowed by an existing subject key. + /// Mirrors Go (a *Account) serviceImportShadowed(from string) bool. + /// + internal bool ServiceImportShadowed(string from) + { + _mu.EnterReadLock(); + try + { + if (Imports.Services == null) + return false; + if (Imports.Services.ContainsKey(from)) + return true; + + foreach (var subject in Imports.Services.Keys) + { + if (SubscriptionIndex.SubjectIsSubsetMatch(from, subject)) + return true; + } + + return false; + } + finally + { + _mu.ExitReadLock(); + } + } + + /// + /// Returns true when a service import already exists for destination account + source subject. + /// Mirrors Go (a *Account) serviceImportExists(dstAccName, from string) bool. + /// + internal bool ServiceImportExists(string destinationAccountName, string from) + { + _mu.EnterReadLock(); + try + { + return GetServiceImportForAccountLocked(destinationAccountName, from) != null; + } + finally + { + _mu.ExitReadLock(); + } + } + + /// + /// Creates (or returns existing) internal account client. + /// Lock must be held. + /// Mirrors Go (a *Account) internalClient() *client. + /// + internal ClientConnection? InternalAccountClient() + { + if (InternalClient == null && Server is NatsServer server) + { + InternalClient = server.CreateInternalAccountClient(); + InternalClient.Account = this; + } + + return InternalClient; + } + + /// + /// Creates internal account-scoped subscription. + /// Mirrors Go (a *Account) subscribeInternal(...). + /// + internal (Subscription? Sub, Exception? Error) SubscribeInternal(string subject) => + SubscribeInternalEx(subject, false); + + /// + /// Unsubscribes from an internal account subscription. + /// Mirrors Go (a *Account) unsubscribeInternal(sub *subscription). + /// + internal void UnsubscribeInternal(Subscription? sub) + { + if (sub?.Sid == null) + return; + + _mu.EnterReadLock(); + var internalClient = InternalClient; + _mu.ExitReadLock(); + internalClient?.ProcessUnsub(sub.Sid); + } + + /// + /// Creates internal subscription for service-import responses. + /// Mirrors Go (a *Account) subscribeServiceImportResponse(subject string). + /// + internal (Subscription? Sub, Exception? Error) SubscribeServiceImportResponse(string subject) => + SubscribeInternalEx(subject, true); + + /// + /// Extended internal subscription helper. + /// Mirrors Go (a *Account) subscribeInternalEx(...). + /// + internal (Subscription? Sub, Exception? Error) SubscribeInternalEx(string subject, bool responseImport) + { + ClientConnection? client; + string sidText; + + _mu.EnterWriteLock(); + try + { + _isid++; + client = InternalAccountClient(); + sidText = _isid.ToString(); + } + finally + { + _mu.ExitWriteLock(); + } + + if (client == null) + return (null, new InvalidOperationException("no internal account client")); + + return client.ProcessSubEx(Encoding.ASCII.GetBytes(subject), null, Encoding.ASCII.GetBytes(sidText), false, false, responseImport); + } + + /// + /// Adds an internal subscription that matches a service import's from subject. + /// Mirrors Go (a *Account) addServiceImportSub(si *serviceImport) error. + /// + internal Exception? AddServiceImportSub(ServiceImportEntry serviceImport) + { + if (serviceImport == null) + return ServerErrors.ErrMissingService; + + ClientConnection? client; + string sidText; + string subject; + + _mu.EnterWriteLock(); + try + { + client = InternalAccountClient(); + if (client == null) + return null; + if (serviceImport.SubscriptionId is { Length: > 0 }) + return new InvalidOperationException("duplicate call to create subscription for service import"); + + _isid++; + sidText = _isid.ToString(); + serviceImport.SubscriptionId = Encoding.ASCII.GetBytes(sidText); + subject = serviceImport.From; + } + finally + { + _mu.ExitWriteLock(); + } + + var (_, err) = client.ProcessSubEx(Encoding.ASCII.GetBytes(subject), null, Encoding.ASCII.GetBytes(sidText), true, true, false); + return err; + } + + /// + /// Removes all subscriptions associated with service imports. + /// Mirrors Go (a *Account) removeAllServiceImportSubs(). + /// + internal void RemoveAllServiceImportSubs() + { + List subscriptionIds = []; + ClientConnection? internalClient; + + _mu.EnterWriteLock(); + try + { + if (Imports.Services != null) + { + foreach (var imports in Imports.Services.Values) + { + foreach (var serviceImport in imports) + { + if (serviceImport.SubscriptionId is { Length: > 0 }) + { + subscriptionIds.Add(serviceImport.SubscriptionId); + serviceImport.SubscriptionId = null; + } + } + } + } + + internalClient = InternalClient; + InternalClient = null; + } + finally + { + _mu.ExitWriteLock(); + } + + if (internalClient == null) + return; + + foreach (var sid in subscriptionIds) + internalClient.ProcessUnsub(sid); + + internalClient.CloseConnection(ClosedState.InternalClient); + } + + /// + /// Adds subscriptions for all registered service imports. + /// Mirrors Go (a *Account) addAllServiceImportSubs(). + /// + internal void AddAllServiceImportSubs() + { + List imports = []; + + _mu.EnterReadLock(); + try + { + if (Imports.Services != null) + { + foreach (var entries in Imports.Services.Values) + imports.AddRange(entries); + } + } + finally + { + _mu.ExitReadLock(); + } + + foreach (var serviceImport in imports) + _ = AddServiceImportSub(serviceImport); + } + + /// + /// Processes a service-import response routed to this account. + /// Mirrors Go (a *Account) processServiceImportResponse(...). + /// + internal void ProcessServiceImportResponse(string subject, byte[] msg) + { + ServiceImportEntry? serviceImport; + + _mu.EnterReadLock(); + try + { + if (IsExpired() || Exports.Responses == null || Exports.Responses.Count == 0) + return; + + if (!Exports.Responses.TryGetValue(subject, out serviceImport)) + return; + if (serviceImport == null || serviceImport.Invalid) + return; + } + finally + { + _mu.ExitReadLock(); + } + + // The client-side response processing pipeline is still under active porting. + serviceImport.DidDeliver = msg.Length >= 0; + } + + /// + /// Creates response wildcard prefix for service replies. + /// Lock must be held by caller. + /// Mirrors Go (a *Account) createRespWildcard(). + /// + internal void CreateRespWildcard() + { + const string alphabet = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"; + Span prefix = stackalloc byte[14]; + prefix[0] = (byte)'_'; + prefix[1] = (byte)'R'; + prefix[2] = (byte)'_'; + prefix[3] = (byte)'.'; + + ulong random = (ulong)Random.Shared.NextInt64(); + for (var i = 4; i < prefix.Length; i++) + { + prefix[i] = (byte)alphabet[(int)(random % (ulong)alphabet.Length)]; + random /= (ulong)alphabet.Length; + } + + ServiceImportReply = [.. prefix, (byte)'.']; + } + // ------------------------------------------------------------------------- // Export checks // ------------------------------------------------------------------------- diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/ClientConnection.cs b/dotnet/src/ZB.MOM.NatsNet.Server/ClientConnection.cs index 60bc7d8..b0dea47 100644 --- a/dotnet/src/ZB.MOM.NatsNet.Server/ClientConnection.cs +++ b/dotnet/src/ZB.MOM.NatsNet.Server/ClientConnection.cs @@ -1682,6 +1682,18 @@ public sealed partial class ClientConnection } } + internal void ProcessUnsub(byte[] sid) + { + lock (_mu) + { + if (Subs == null) + return; + + var sidText = Encoding.ASCII.GetString(sid); + Subs.Remove(sidText); + } + } + // features 440-441: processInfo, processErr internal void ProcessInfo(string info) { diff --git a/porting.db b/porting.db index 6ec2d7821979374c35cbbd000972b27e1da33c67..b8b7dd8af34c1826986988f973757d3fe1ccbe0c 100644 GIT binary patch delta 5690 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 p^OkCEw#|B5kv%E=@kLf(!5*{Ux9+7 ze2$f=DU!03GCi!OHKEJas%BKObb6p7IcBIyq>ZKMd|cw(d)AuyZx-vv+P`n_{he=r zXWzSSaq%XuxDmL@2w^?Lh*?$9kF)FF%T6B5R+VlWY;GIOzP6Qj%r~md*d}(7;TT9c z7kN3aVD^mJdAUp#)Sh8KcMRwX=H|`FD=5gz)$f4!EL+NM0HK9_cw?4xsQFY2JC`-j zoMVp@$CuJE#+W{TUjE#?qCDn=`O$f{l_RtUwq9hxbAi?{KUuT?LTj%g&+ITGoQL*|M`}<(8d6TV~m54{E-p&1gB6eTSA|*|%tEmVJYkXxZ0jah9Dz zi?r+{n*aD;bv3SMPD`U^LK_=Hf&$yg-03oyb(u}^*y6w0?1s($ZL?o(cHL&zZ1#)I zezsXV^#r_S=BjP-C!1Zd*=3tuvRRwWeze&|oBd#{Fl(={FRbUuEoQGN6qBhew0cPh zw0nvC|NAGJH@t*rJzO)h*}BrNaC4bN%NC=>S+)o*(y}5n!?MD_Bj|H6XPPzl5}H5f z95jE<>0Cr}K+;T{^e3GH)ukkj=VvkbP?2t z!!SpBj;U=l>is*l4QSR*?a#Jn;FmO0Ztbf=TV`1$TE1oL&~hwWi)OV|t6)Ym{^M)q zIB6YUdmYU>zP1LQWQ4)R=E(xcF6O<_S}U2|}unAoLUZ2pZqXU**s7U-F;uCjTz~24BH@_yw>w+i_oC zdYFE|r_)Vz4P8pzd=h<@j-tb8e|{j<_1nCQckt}SY!V&G{i1!XeXbqS8nk^{jrNvy zfris;T4O{RArQ@GMR@Mkf;2(BpGrRvM-Y;}q{PEAo0)c$IO zYN)JoP5D9jRynFPD*KfhNAf;- zyS!0eB`=ZP@-#U^PM1f>_sh}pU9u?sCSCSOXQdO;CsKp7N7^D)Nz0{0(i|yM8ZV_v z!=wRHq~wr@cwM|GekUFi4~ZX$yTr|6g;*>WiZjF)#IfSz;!yD(W1Nv0_4I4bdt&c@&q8qh=hv2<}ejaMQD$3}TJI`%X^+);FB}7}nO2 z2=)Lxww#26TuK~}R8F3A(rDZ2DA-*_!kyG*i$=n%<-`paOUZNyEhXs=opZ50zhQEU z7Q)-Noqf!cJIHAsqIZ*}aAG$}F=Xy8;+{H<8$Zg;|IR?{9&!a z3^HKJ4)TzOA4S#I!PY|9^A33$l6H_3xLrq%Ktnl63sdDS%ynWga;01%7s)f^$;41! zRm1Qz;S7dxBOQvNs?uV-ic_R#q-5n5xLeo|2&yMzA-`qXBxtTDznUHIk>7c~t2k&s zNRGnky`&cQ?ITINp3B5RZm07>^JD|*$HLvWoDmTEAvRg>bcUN7J|y|U@|$`l)+uJ0 zdJ{=x;foPrFPd8>I6hWaW;PRqLwH0$L_|VlL_xaNXl7=!p8gKR9u{2CK9kDunTN(f zX2o-C#KjE;s-7c_lJdx5iz_Ji@aBOH=9zZb)%&WBzlG#ECSl*0TnIs<~z zcvtmG+8e|bG|HDl)xGnB7&hH|?+|DSit63HIBNwh12UTSh0di^s$M}w=)IE0bl0D{ zlGZ}dO{Kd`$10joeMfefOJApdCoo|Z-3sdy#jZ&e!dBD!eVaJlyBI25t_WDLnpQxL z?&=GbwV1a5nm<-3y}gUDkW}x;?xMYG=$p`SfcGCwgcB!}dm(uY#~-YuvNM!F&f!lo zrqJl5$MiPshI(EJmyU}tZQHO6v*}$LFGAr~dJKjOu0>o$71yPf!Qb$D$D zHDK{J8s1eXHQTn+IV_M(lJDL7rhV_;fCD?|e%M|mcOPxt&Ysuv*-p9v4&RJ%LHv{0 z=<@156du^F$97dJz+Y?VE6@?_YlsAschRKoF222s9)*s=kUw@+*WwlP-GXDabeHcI zKvPJR25-Gf@AFkwov({4#d?Yuhy!sVy^tW}E~GaSjD#SeNEp%w2}dH3NW_KQjYJ{Q zNDLB-+=KK*?nUlH`XT+10Z1G&5E+Erk34`3M&gkn$WY`#