diff --git a/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/LazyBrowseNodeTests.cs b/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/LazyBrowseNodeTests.cs index 96060b9..3a40275 100644 --- a/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/LazyBrowseNodeTests.cs +++ b/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/LazyBrowseNodeTests.cs @@ -142,6 +142,37 @@ public sealed class LazyBrowseNodeTests Assert.Equal("7:abc:2", transport.BrowseChildrenCalls[2].Request.PageToken); } + /// + /// Verifies that ten concurrent ExpandAsync calls issue exactly one RPC, not ten. + /// + [Fact] + public async Task Expand_CalledConcurrently_OnlyFiresOneRpc() + { + FakeGalaxyRepositoryTransport transport = CreateTransport(); + transport.BrowseChildrenReplies.Enqueue(BuildReply( + children: [BuildObject(1, "Plant", isArea: true)], + childHasChildren: [true], + cacheSequence: 7)); + transport.BrowseChildrenReplies.Enqueue(BuildReply( + children: [BuildObject(2, "Mixer_001")], + childHasChildren: [false], + cacheSequence: 7)); + + await using GalaxyRepositoryClient client = CreateClient(transport); + IReadOnlyList roots = await client.BrowseAsync(); + + // Fire ten concurrent expands of the same node. + Task[] tasks = Enumerable.Range(0, 10) + .Select(_ => roots[0].ExpandAsync()) + .ToArray(); + await Task.WhenAll(tasks); + + Assert.True(roots[0].IsExpanded); + Assert.Single(roots[0].Children); + // 1 roots fetch + exactly 1 expand fetch = 2 total + Assert.Equal(2, transport.BrowseChildrenCalls.Count); + } + /// /// Verifies that BrowseChildrenOptions filter fields are forwarded to the BrowseChildren request. /// diff --git a/clients/dotnet/ZB.MOM.WW.MxGateway.Client/LazyBrowseNode.cs b/clients/dotnet/ZB.MOM.WW.MxGateway.Client/LazyBrowseNode.cs index 35e3bb9..360f4f5 100644 --- a/clients/dotnet/ZB.MOM.WW.MxGateway.Client/LazyBrowseNode.cs +++ b/clients/dotnet/ZB.MOM.WW.MxGateway.Client/LazyBrowseNode.cs @@ -13,6 +13,7 @@ public sealed class LazyBrowseNode private readonly GalaxyRepositoryClient _client; private readonly BrowseChildrenOptions _options; private readonly List _children = []; + private readonly SemaphoreSlim _expandLock = new(1, 1); private bool _isExpanded; internal LazyBrowseNode( @@ -43,6 +44,10 @@ public sealed class LazyBrowseNode /// Fetches direct children from the gateway and populates . /// Idempotent: subsequent calls are no-ops. /// + /// + /// Thread-safe: concurrent callers see exactly one fetch; subsequent callers + /// (after the first completes) return immediately. + /// /// Token to observe for cancellation. public async Task ExpandAsync(CancellationToken cancellationToken = default) { @@ -51,33 +56,46 @@ public sealed class LazyBrowseNode return; } - string pageToken = string.Empty; - HashSet seenPageTokens = new(StringComparer.Ordinal); - do + await _expandLock.WaitAsync(cancellationToken).ConfigureAwait(false); + try { - BrowseChildrenRequest request = GalaxyRepositoryClient.BuildBrowseChildrenRequest(_options); - request.ParentGobjectId = Object.GobjectId; - request.PageToken = pageToken; - - BrowseChildrenReply reply = await _client - .BrowseChildrenRawAsync(request, cancellationToken) - .ConfigureAwait(false); - - for (int i = 0; i < reply.Children.Count; i++) + if (_isExpanded) { - bool hint = i < reply.ChildHasChildren.Count && reply.ChildHasChildren[i]; - _children.Add(new LazyBrowseNode(_client, reply.Children[i], hint, _options)); + return; } - pageToken = reply.NextPageToken; - if (!string.IsNullOrWhiteSpace(pageToken) && !seenPageTokens.Add(pageToken)) + string pageToken = string.Empty; + HashSet seenPageTokens = new(StringComparer.Ordinal); + do { - throw new MxGatewayException( - $"Galaxy BrowseChildren returned a repeated page token '{pageToken}'."); + BrowseChildrenRequest request = GalaxyRepositoryClient.BuildBrowseChildrenRequest(_options); + request.ParentGobjectId = Object.GobjectId; + request.PageToken = pageToken; + + BrowseChildrenReply reply = await _client + .BrowseChildrenRawAsync(request, cancellationToken) + .ConfigureAwait(false); + + for (int i = 0; i < reply.Children.Count; i++) + { + bool hint = i < reply.ChildHasChildren.Count && reply.ChildHasChildren[i]; + _children.Add(new LazyBrowseNode(_client, reply.Children[i], hint, _options)); + } + + pageToken = reply.NextPageToken; + if (!string.IsNullOrWhiteSpace(pageToken) && !seenPageTokens.Add(pageToken)) + { + throw new MxGatewayException( + $"Galaxy BrowseChildren returned a repeated page token '{pageToken}'."); + } } + while (!string.IsNullOrWhiteSpace(pageToken)); + + _isExpanded = true; + } + finally + { + _expandLock.Release(); } - while (!string.IsNullOrWhiteSpace(pageToken)); - - _isExpanded = true; } }