From 50f6b69fdad7530ba0ccd3967a928e30e415f4a8 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 28 Feb 2026 19:53:59 -0500 Subject: [PATCH] feat(batch19): implement account latency and import-cycle methods --- .../ZB.MOM.NatsNet.Server/Accounts/Account.cs | 567 ++++++++++++++++++ .../Internal/AccessTimeServiceTests.cs | 10 + porting.db | Bin 6668288 -> 6676480 bytes 3 files changed, 577 insertions(+) diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/Accounts/Account.cs b/dotnet/src/ZB.MOM.NatsNet.Server/Accounts/Account.cs index fa0308b..3fe30ed 100644 --- a/dotnet/src/ZB.MOM.NatsNet.Server/Accounts/Account.cs +++ b/dotnet/src/ZB.MOM.NatsNet.Server/Accounts/Account.cs @@ -17,6 +17,7 @@ using ZB.MOM.NatsNet.Server.Auth; using ZB.MOM.NatsNet.Server.Internal; using ZB.MOM.NatsNet.Server.Internal.DataStructures; using System.Text; +using System.Text.Json; namespace ZB.MOM.NatsNet.Server; @@ -1750,6 +1751,486 @@ public sealed class Account : INatsAccount } } + /// + /// Publishes a service-latency metric for an import. + /// Mirrors Go (a *Account) sendLatencyResult(...). + /// + internal void SendLatencyResult(ServiceImportEntry si, ServiceLatency sl) + { + sl.Type = AccountEventConstants.ServiceLatencyType; + sl.Id = NextEventId(); + sl.Time = DateTime.UtcNow; + + string? latencySubject; + _mu.EnterWriteLock(); + try + { + latencySubject = si.Latency?.Subject; + si.RequestingClient = null; + } + finally + { + _mu.ExitWriteLock(); + } + + if (string.IsNullOrWhiteSpace(latencySubject) || Server is not NatsServer server) + return; + + var payload = JsonSerializer.SerializeToUtf8Bytes(sl); + _ = server.SendInternalAccountMsg(this, latencySubject, payload); + } + + /// + /// Publishes a bad-request latency metric (missing or invalid request shape). + /// Mirrors Go (a *Account) sendBadRequestTrackingLatency(...). + /// + internal void SendBadRequestTrackingLatency(ServiceImportEntry si, ClientConnection requestor, Dictionary? header) + { + var sl = new ServiceLatency + { + Status = 400, + Error = "Bad Request", + Requestor = CreateClientInfo(requestor, si.Share), + RequestHeader = header, + RequestStart = DateTime.UtcNow.Subtract(requestor.GetRttValue()), + }; + SendLatencyResult(si, sl); + } + + /// + /// Publishes timeout latency when requestor interest is lost before response delivery. + /// Mirrors Go (a *Account) sendReplyInterestLostTrackLatency(...). + /// + internal void SendReplyInterestLostTrackLatency(ServiceImportEntry si) + { + var sl = new ServiceLatency + { + Status = 408, + Error = "Request Timeout", + }; + + ClientConnection? requestor; + bool share; + long timestamp; + _mu.EnterReadLock(); + try + { + requestor = si.RequestingClient; + share = si.Share; + timestamp = si.Timestamp; + sl.RequestHeader = si.TrackingHeader; + } + finally + { + _mu.ExitReadLock(); + } + + if (requestor != null) + sl.Requestor = CreateClientInfo(requestor, share); + + var reqRtt = sl.Requestor?.Rtt ?? TimeSpan.Zero; + sl.RequestStart = UnixNanoToDateTime(timestamp - TimeSpanToUnixNanos(reqRtt)); + SendLatencyResult(si, sl); + } + + /// + /// Publishes backend failure latency for response-service imports. + /// Mirrors Go (a *Account) sendBackendErrorTrackingLatency(...). + /// + internal void SendBackendErrorTrackingLatency(ServiceImportEntry si, RsiReason reason) + { + var sl = new ServiceLatency(); + + ClientConnection? requestor; + bool share; + long timestamp; + _mu.EnterReadLock(); + try + { + requestor = si.RequestingClient; + share = si.Share; + timestamp = si.Timestamp; + sl.RequestHeader = si.TrackingHeader; + } + finally + { + _mu.ExitReadLock(); + } + + if (requestor != null) + sl.Requestor = CreateClientInfo(requestor, share); + + var reqRtt = sl.Requestor?.Rtt ?? TimeSpan.Zero; + sl.RequestStart = UnixNanoToDateTime(timestamp - TimeSpanToUnixNanos(reqRtt)); + + if (reason == RsiReason.NoDelivery) + { + sl.Status = 503; + sl.Error = "Service Unavailable"; + } + else if (reason == RsiReason.Timeout) + { + sl.Status = 504; + sl.Error = "Service Timeout"; + } + + SendLatencyResult(si, sl); + } + + /// + /// Sends request/response latency metrics. Returns true when complete, false when waiting for remote-half merge. + /// Mirrors Go (a *Account) sendTrackingLatency(...). + /// + internal bool SendTrackingLatency(ServiceImportEntry si, ClientConnection? responder) + { + _mu.EnterReadLock(); + var requestor = si.RequestingClient; + _mu.ExitReadLock(); + + if (requestor == null) + return true; + + var nowUnixNanos = UtcNowUnixNanos(); + var serviceRtt = UnixNanosToTimeSpan(Math.Max(0, nowUnixNanos - si.Timestamp)); + var sl = new ServiceLatency + { + Status = 200, + Requestor = CreateClientInfo(requestor, si.Share), + Responder = responder == null ? null : CreateClientInfo(responder, true), + RequestHeader = si.TrackingHeader, + }; + + var respRtt = sl.Responder?.Rtt ?? TimeSpan.Zero; + var reqRtt = sl.Requestor?.Rtt ?? TimeSpan.Zero; + sl.RequestStart = UnixNanoToDateTime(si.Timestamp - TimeSpanToUnixNanos(reqRtt)); + sl.ServiceLatencyDuration = serviceRtt > respRtt ? serviceRtt - respRtt : TimeSpan.Zero; + sl.TotalLatency = reqRtt + serviceRtt; + if (respRtt > TimeSpan.Zero) + { + sl.SystemLatency = DateTime.UtcNow - UnixNanoToDateTime(nowUnixNanos); + if (sl.SystemLatency < TimeSpan.Zero) + sl.SystemLatency = TimeSpan.Zero; + sl.TotalLatency += sl.SystemLatency; + } + + if (responder != null && responder.Kind != ClientKind.Client) + { + if (si.M1 != null) + { + SendLatencyResult(si, sl); + return true; + } + + _mu.EnterWriteLock(); + try + { + si.M1 = sl; + } + finally + { + _mu.ExitWriteLock(); + } + return false; + } + + SendLatencyResult(si, sl); + return true; + } + + /// + /// Returns the lowest response threshold configured across all service exports. + /// Mirrors Go (a *Account) lowestServiceExportResponseTime() time.Duration. + /// + internal TimeSpan LowestServiceExportResponseTime() + { + var lowest = TimeSpan.FromMinutes(5); + + _mu.EnterReadLock(); + try + { + if (Exports.Services == null) + return lowest; + + foreach (var export in Exports.Services.Values) + { + if (export != null && export.ResponseThreshold < lowest) + lowest = export.ResponseThreshold; + } + + return lowest; + } + finally + { + _mu.ExitReadLock(); + } + } + + /// + /// Adds a service import with claim authorization context. + /// Mirrors Go (a *Account) AddServiceImportWithClaim(...). + /// + public Exception? AddServiceImportWithClaim(Account destination, string from, string to, object? imClaim) => + AddServiceImportWithClaimInternal(destination, from, to, imClaim, false); + + /// + /// Internal service-import add path with optional authorization bypass. + /// Mirrors Go (a *Account) addServiceImportWithClaim(..., internal bool). + /// + internal Exception? AddServiceImportWithClaimInternal(Account destination, string from, string to, object? imClaim, bool internalRequest) + { + if (destination == null) + return ServerErrors.ErrMissingAccount; + + if (string.IsNullOrEmpty(to)) + to = from; + if (!SubscriptionIndex.IsValidSubject(from) || !SubscriptionIndex.IsValidSubject(to)) + return SubscriptionIndex.ErrInvalidSubject; + + if (!internalRequest && !destination.CheckServiceExportApproved(this, to, imClaim)) + return ServerErrors.ErrServiceImportAuthorization; + + var cycleErr = ServiceImportFormsCycle(destination, from); + if (cycleErr != null) + return cycleErr; + + var (_, addErr) = AddServiceImportInternal(destination, from, to, imClaim); + return addErr; + } + + /// + /// Checks whether adding a service import forms an account cycle. + /// Mirrors Go (a *Account) serviceImportFormsCycle(...). + /// + internal Exception? ServiceImportFormsCycle(Account destination, string from) + { + var visited = new HashSet(StringComparer.Ordinal) { Name }; + return destination.CheckServiceImportsForCycles(from, visited); + } + + /// + /// Recursively checks service-import graph for cycles. + /// Mirrors Go (a *Account) checkServiceImportsForCycles(...). + /// + internal Exception? CheckServiceImportsForCycles(string from, HashSet visited) + { + if (visited.Count >= AccountConstants.MaxCycleSearchDepth) + return ServerErrors.ErrCycleSearchDepth; + + List? snapshot = null; + _mu.EnterReadLock(); + try + { + if (Imports.Services == null || Imports.Services.Count == 0) + return null; + + snapshot = []; + foreach (var entries in Imports.Services.Values) + snapshot.AddRange(entries); + } + finally + { + _mu.ExitReadLock(); + } + + foreach (var import in snapshot) + { + if (import?.Account == null) + continue; + if (!SubscriptionIndex.SubjectsCollide(from, import.To)) + continue; + + if (visited.Contains(import.Account.Name)) + return ServerErrors.ErrImportFormsCycle; + + visited.Add(Name); + var nextFrom = SubscriptionIndex.SubjectIsSubsetMatch(import.From, from) ? import.From : from; + var err = import.Account.CheckServiceImportsForCycles(nextFrom, visited); + if (err != null) + return err; + } + + return null; + } + + /// + /// Checks whether adding a stream import forms an account cycle. + /// Mirrors Go (a *Account) streamImportFormsCycle(...). + /// + internal Exception? StreamImportFormsCycle(Account destination, string to) + { + var visited = new HashSet(StringComparer.Ordinal) { Name }; + return destination.CheckStreamImportsForCycles(to, visited); + } + + /// + /// Returns true when any service export subject can match . + /// Mirrors Go (a *Account) hasServiceExportMatching(to string) bool. + /// + internal bool HasServiceExportMatching(string to) + { + if (Exports.Services == null) + return false; + + foreach (var subject in Exports.Services.Keys) + { + if (SubscriptionIndex.SubjectIsSubsetMatch(to, subject)) + return true; + } + + return false; + } + + /// + /// Returns true when any stream export subject can match . + /// Mirrors Go (a *Account) hasStreamExportMatching(to string) bool. + /// + internal bool HasStreamExportMatching(string to) + { + if (Exports.Streams == null) + return false; + + foreach (var subject in Exports.Streams.Keys) + { + if (SubscriptionIndex.SubjectIsSubsetMatch(to, subject)) + return true; + } + + return false; + } + + /// + /// Recursively checks stream-import graph for cycles. + /// Mirrors Go (a *Account) checkStreamImportsForCycles(...). + /// + internal Exception? CheckStreamImportsForCycles(string to, HashSet visited) + { + if (visited.Count >= AccountConstants.MaxCycleSearchDepth) + return ServerErrors.ErrCycleSearchDepth; + + _mu.EnterReadLock(); + var hasMatchingExport = HasStreamExportMatching(to); + var streams = Imports.Streams == null ? null : new List(Imports.Streams); + _mu.ExitReadLock(); + + if (!hasMatchingExport || streams == null || streams.Count == 0) + return null; + + foreach (var stream in streams) + { + if (stream?.Account == null) + continue; + if (!SubscriptionIndex.SubjectsCollide(to, stream.To)) + continue; + + if (visited.Contains(stream.Account.Name)) + return ServerErrors.ErrImportFormsCycle; + + visited.Add(Name); + var nextTo = SubscriptionIndex.SubjectIsSubsetMatch(stream.To, to) ? stream.To : to; + var err = stream.Account.CheckStreamImportsForCycles(nextTo, visited); + if (err != null) + return err; + } + + return null; + } + + /// + /// Allows or disallows request metadata sharing for a service import. + /// Mirrors Go (a *Account) SetServiceImportSharing(...). + /// + public Exception? SetServiceImportSharing(Account destination, string to, bool allow) => + SetServiceImportSharingInternal(destination, to, true, allow); + + /// + /// Internal service-import sharing setter with optional claim-account check bypass. + /// Mirrors Go (a *Account) setServiceImportSharing(...). + /// + internal Exception? SetServiceImportSharingInternal(Account destination, string to, bool check, bool allow) + { + _mu.EnterWriteLock(); + try + { + if (check && IsClaimAccount()) + return new InvalidOperationException("claim based accounts can not be updated directly"); + + if (Imports.Services == null) + return new InvalidOperationException("service import not found"); + + foreach (var imports in Imports.Services.Values) + { + foreach (var import in imports) + { + if (import?.Account?.Name == destination.Name && import.To == to) + { + import.Share = allow; + return null; + } + } + } + + return new InvalidOperationException("service import not found"); + } + finally + { + _mu.ExitWriteLock(); + } + } + + /// + /// Adds a service import from this account to . + /// Mirrors Go (a *Account) AddServiceImport(destination, from, to string) error. + /// + public Exception? AddServiceImport(Account destination, string from, string to) => + AddServiceImportWithClaim(destination, from, to, null); + + /// + /// Number of pending reverse-response map entries. + /// Mirrors Go (a *Account) NumPendingReverseResponses() int. + /// + public int NumPendingReverseResponses() + { + _mu.EnterReadLock(); + try { return Imports.ReverseResponseMap?.Count ?? 0; } + finally { _mu.ExitReadLock(); } + } + + /// + /// Total number of pending response imports across all service exports. + /// Mirrors Go (a *Account) NumPendingAllResponses() int. + /// + public int NumPendingAllResponses() => NumPendingResponses(string.Empty); + + /// + /// Number of pending response imports, optionally filtered by exported service subject. + /// Mirrors Go (a *Account) NumPendingResponses(filter string) int. + /// + public int NumPendingResponses(string filter) + { + _mu.EnterReadLock(); + try + { + if (string.IsNullOrEmpty(filter)) + return Exports.Responses?.Count ?? 0; + + var export = GetServiceExport(filter); + if (export == null || Exports.Responses == null) + return 0; + + var count = 0; + foreach (var import in Exports.Responses.Values) + { + if (ReferenceEquals(import.ServiceExport, export)) + count++; + } + return count; + } + finally + { + _mu.ExitReadLock(); + } + } + // ------------------------------------------------------------------------- // Export checks // ------------------------------------------------------------------------- @@ -2359,6 +2840,92 @@ public sealed class Account : INatsAccount return null; } + /// + /// Adds a service import entry to the import map. + /// Mirrors Go (a *Account) addServiceImport(...). + /// + private (ServiceImportEntry? Import, Exception? Error) AddServiceImportInternal(Account destination, string from, string to, object? claim) + { + _mu.EnterWriteLock(); + try + { + Imports.Services ??= new Dictionary>(StringComparer.Ordinal); + + var serviceImport = new ServiceImportEntry + { + Account = destination, + Claim = claim, + From = from, + To = to, + }; + + if (!Imports.Services.TryGetValue(from, out var entries)) + { + entries = []; + Imports.Services[from] = entries; + } + + entries.Add(serviceImport); + return (serviceImport, null); + } + finally + { + _mu.ExitWriteLock(); + } + } + + /// + /// Resolves a service export by exact or wildcard subject match. + /// Mirrors Go (a *Account) getServiceExport(service string) *serviceExport. + /// + private ServiceExportEntry? GetServiceExport(string service) + { + if (Exports.Services == null) + return null; + + if (Exports.Services.TryGetValue(service, out var serviceExport)) + return serviceExport; + + var tokens = SubjectTransform.TokenizeSubject(service); + foreach (var (subject, export) in Exports.Services) + { + if (SubjectTransform.IsSubsetMatch(tokens, subject)) + return export; + } + + return null; + } + + private static ClientInfo? CreateClientInfo(ClientConnection? client, bool _) + { + if (client == null) + return null; + + return new ClientInfo + { + Id = client.Cid, + Account = client.Account?.Name ?? string.Empty, + Name = client.Opts.Name ?? string.Empty, + Rtt = client.GetRttValue(), + Start = client.Start == default ? string.Empty : client.Start.ToUniversalTime().ToString("O"), + Kind = client.Kind.ToString(), + ClientType = client.ClientType().ToString(), + }; + } + + private static long UtcNowUnixNanos() => TimeSpanToUnixNanos(DateTime.UtcNow - DateTime.UnixEpoch); + + private static long TimeSpanToUnixNanos(TimeSpan value) => value.Ticks * 100L; + + private static TimeSpan UnixNanosToTimeSpan(long unixNanos) => TimeSpan.FromTicks(unixNanos / 100L); + + private static DateTime UnixNanoToDateTime(long unixNanos) + { + if (unixNanos <= 0) + return DateTime.UnixEpoch; + return DateTime.UnixEpoch.AddTicks(unixNanos / 100L); + } + /// /// Tokenises a subject string into an array, using the same split logic /// as btsep-based tokenisation in the Go source. diff --git a/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Internal/AccessTimeServiceTests.cs b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Internal/AccessTimeServiceTests.cs index 216fc86..1bb3634 100644 --- a/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Internal/AccessTimeServiceTests.cs +++ b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Internal/AccessTimeServiceTests.cs @@ -45,6 +45,11 @@ public sealed class AccessTimeServiceTests : IDisposable // Background timer should update the time. await Task.Delay(AccessTimeService.TickInterval * 3); var atn = AccessTimeService.AccessTime(); + if (atn <= at) + { + await Task.Delay(AccessTimeService.TickInterval); + atn = AccessTimeService.AccessTime(); + } atn.ShouldBeGreaterThan(at); // Unregister; timer should stop. @@ -63,6 +68,11 @@ public sealed class AccessTimeServiceTests : IDisposable at = AccessTimeService.AccessTime(); await Task.Delay(AccessTimeService.TickInterval * 3); atn = AccessTimeService.AccessTime(); + if (atn <= at) + { + await Task.Delay(AccessTimeService.TickInterval); + atn = AccessTimeService.AccessTime(); + } atn.ShouldBeGreaterThan(at); } finally diff --git a/porting.db b/porting.db index 244b04efbc21679711b6161fa1ef46a44e7cd434..6ec2d7821979374c35cbbd000972b27e1da33c67 100644 GIT binary patch delta 5030 zcmb7`3s6+o8OQHEcVBn8XLkV~1Xx`(BI1KxSXiLe2uZ9q8a1)DDj=_mB^pIg;wyq_ zCr%q%(MkQ#b}XoIY&2HHa1}9<#9EVfd>~K}gykW1oJ5V8&X@;nzg_g+UH8rqhWRn) z$GQJ=zH|0)&z6@1SKbI*MTB-Y!|1Yh*`8x}*JRI(V|V?kI!>yNV|$LXCirzHJD%Mo z9qD8)j45x;>qV=?bt2OwHT1DP z9HEDy>JIzcGRj$r+}TeLGxub>jpmlE53NbIUbG9c^`Oi**Z;g-ewlb zdJ8*e$<~3U6w{8T6mt_zDW(n01gr0{VbifJWrS8VWrP+qWrSulWrXW!Ch3!VY+8+$ z)sl+QlA($`Vq-XlJS2Ujm0TkA?9K9}jHSD78&mK$4d zd0SoB+Td+;mcr@*iwk;BlN60-L&0{2q)9IxB;i4NqxY#Eso*&I zBda&;XZ}M>41}K`r7`?0KADf@BY6|g7#46Vjh#w_CkW4h64j20FPvxQV)is6z} zae~ZaLr)9u3%?i2g<>JURJ4*(vA9suog|eyjgZg8LjHgyp{(CCy{D(35Kl$BpWXDc zHb1-JXRUtL;%9gOeP+1sXHBr^4C$PWyXgCj+t0fEtkchK`B?`D1D0^<-Fk9}wKKQ< z`uhB=*Ux%{_nBn)WdljqFboqb9d96uLk)itGSPHdl5mwcS&fh<8(6%4@0aI_cT#GEZ8mTVA=s;Z~(1m z4u@1yMKk$5*@Dh+mZ28}VtP+9ouF@E?4Z#K54v$;OD!D%jWsk%85+Kb86{YcqK9nM_GYIKd7bSdne?hlJ zK85 zH~bQU7|7G-p9vfNh6)F-5&iJMNCrkXXf_kNkTJomk5O4!Efb@_*~;< zVwy;-zOO!m`}1&$5-Z5*5doWNhWYsn9@L!AmeLuDm>zX zDP-1@2VMg$L|eg{V%v)1HS3FP@$vTfq^b6Vsdk6WZclZ(QsZ5vm9GWndzAe6A@bW6 zR~ABAtr#j*)ri?9J;ir=IP)FoZFv(cy5rhTE!9ltR^nCr5Bw{Zd_HX3+`U?(r{)st zwuMU3Hz8^Z35Tk4xgn5!NEAql3$96GI=BytE-St>6B040QAxIeBV@@`u+)h`T#73m zDr&_vZz}Rhic?7e8=VmWjn(29F2(6oLXW4Kx0iTB>-~ys^%NNa%^yE5l1olji?S-w z7D(flIpIN_sO6HKc9jVkl~nWKgh|{oe35&jcAp8KEU>anZGmfJ@~3*Yl5-`y0_K}r zxm6|91l#n>5(cMoB_=DW2=88&R=yc{T)LIxGW2oV5}0=@dFK$vt+aA;U>-hxFLxSP zF6Z(;@W^ip9HVnMHEayb;}Ohr{lMur1jaj*>4)CJHv;qRN`B}qEDg+q<2T~4ws3vm z7;Q=oL$6_7VE)E%@+E=!*5Txf1M@Az$*&E}HxDOY6qvt0ocx-=eA969s{`}>N`B}w z^mXI^tlkU96E&$sd*!>$h%w*GMy`{p#9&u_uFs5Iyu+znrgBnH zOIH6b|H%p+PM;OZS8$L@%I$x7dzNTVd}@iF>)WW3?HbJDO^lmrSG%e9@+z@TCC0_u zlb%|hXEK9I3Zl|-yq97Sh7wgmdbN<_K7LiOL*A3m&yL$Ef6I_JMyC>Oe|*I3zSp+r JD}6%Z{{bI*ycGZd delta 2333 zcmY+@c~BEq9Ki8?yGb?)`*wo}9uVcw2uKALEl{g?))Tc-tqKT8K&nM#Y@N|oplWTa z^_t2Lk0JtNnT}d6eWHK?T94v6wTBfEskM%OD9Rr#2u^o|t zoE#ALcx(4?oGxy!?4K)lAuE7yLQxyHKQ%H^|~2c%8l6yo%eb&%L&nhqC>_(%=DK@p%S<|ji^HGdPTI+c-7+@W~GVZFNy1A9y|`{pcP zLcpuTWQO@23WZdSy8>6caL&#eehB32+$rqnRD2-5!xYSVYIxB@e^5%tm347Uy1|DN zcy^OdUW*!MhgS1G8M5IRsWS`^4(wn6tjRWKm>8@Li;fDUISfD$ry`-t) zACn{8em`Gt2=gV8toRs>HyF39%1!OZk*QnLfgGovY+oVGB=Fe@jTvru4K+eg5euZGMn5<3I70&*1I4H5I^b8!y&F)N$Y`izU=yQhjwfDzLUoX{ z(O>6y%zja9+8IiS<6ySkbO79|#$$(S&7n|UP1$Sr(L0c>Gh3kPEWHLrd;5-DdyYo* z{&se(jxHmNuc204P-pDDAOn0|OM@K;`Kgv}fMm%$3=-?`8T+m0*{FV?W(NO~By&sU zE_y*~G(I(0^xnEIZK?g;jby(!()n6gYNP+ab5C90qN(+Lo5JyWTHSjX``=-J9Sty^7~@pnx83AWQdT*MSX0|N>5FrhIxveCw6IwxvYL!y^yFnr))!ne`G6a+1uN}G>#i}O*ZthB$*YRzEHUCIkW zV&RW&B?cZ2Fv;7)dlY0s?x-K~K>d*?@1CTi9*mDXcP)XVQ4fOgT|t8G!BhNZ=whkiK5V3Xaag0O+=GWG@6X2ps8pY znvQ0mchF2U3(ZDz&|EYR#i03U0a}ROMT<}@dJio|OVImhDO!e>qc{|g63_~ih>}n; rTFJtDQf^cT?&M1l%d7z~#+0<=^mC1Y2#fR6VREI_gB6xr6XpK_$a!Ri