File: Backchannel\ResourceSnapshotWatcher.cs
Web Access
Project: src\src\Aspire.Cli\Aspire.Cli.csproj (aspire)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Runtime.CompilerServices;
using System.Threading.Channels;
using Microsoft.Extensions.Logging;
 
namespace Aspire.Cli.Backchannel;
 
/// <summary>
/// Watches for resource snapshot changes from an AppHost backchannel connection
/// and maintains an up-to-date collection of resources.
/// </summary>
internal sealed class ResourceSnapshotWatcher : IDisposable
{
    internal const int UpdateBufferCapacity = 256;
 
    private readonly IAppHostAuxiliaryBackchannel _connection;
    private readonly Dictionary<string, ResourceSnapshot> _resources = new(StringComparers.ResourceName);
    private readonly ILogger<ResourceSnapshotWatcher> _logger;
    private readonly Channel<bool>? _updateSignal;
    private readonly Dictionary<string, ResourceSnapshotUpdate>? _pendingUpdates;
    private readonly object _resourcesLock = new();
    private readonly CancellationTokenSource _cts = new();
    private readonly TaskCompletionSource _initialLoadTcs = new(TaskCreationOptions.RunContinuationsAsynchronously);
    private readonly Task _watchTask;
    private int _updateConsumerClaimed;
    private long _updateSequence;
    private bool _resyncPending;
    public ResourceSnapshotWatcher(
        IAppHostAuxiliaryBackchannel connection,
        ILogger<ResourceSnapshotWatcher> logger,
        bool includeHidden = false,
        bool bufferUpdates = false)
    {
        _connection = connection;
        _logger = logger;
        IncludeHidden = includeHidden;
        if (bufferUpdates)
        {
            _updateSignal = Channel.CreateBounded<bool>(
                new BoundedChannelOptions(1)
                {
                    SingleReader = true,
                    SingleWriter = true,
                    FullMode = BoundedChannelFullMode.DropWrite
                });
            _pendingUpdates = new Dictionary<string, ResourceSnapshotUpdate>(StringComparers.ResourceName);
        }
        _watchTask = WatchAsync(_cts.Token);
    }
 
    /// <summary>
    /// Gets a value indicating whether hidden resources are included by default in <see cref="GetResources()"/>.
    /// </summary>
    public bool IncludeHidden { get; }
 
    /// <summary>
    /// Waits until the initial resource snapshot load is complete.
    /// </summary>
    public Task WaitForInitialLoadAsync(CancellationToken cancellationToken = default)
    {
        return _initialLoadTcs.Task.WaitAsync(cancellationToken);
    }
 
    private async Task WatchAsync(CancellationToken cancellationToken)
    {
        try
        {
            if (!_connection.SupportsResourceSnapshotVersionsV1)
            {
                // Legacy peers always report version 0, so concurrent GET/watch results cannot be reconciled.
                // Preserve their original ordering by seeding the GET snapshot before starting the watch.
                var snapshots = await _connection.GetResourceSnapshotsAsync(includeHidden: true, cancellationToken).ConfigureAwait(false);
                lock (_resourcesLock)
                {
                    foreach (var snapshot in snapshots)
                    {
                        _resources[snapshot.Name] = snapshot;
                    }
                }
 
                _initialLoadTcs.TrySetResult();
                await WatchChangesAsync(cancellationToken).ConfigureAwait(false);
            }
            else
            {
                // Start the watch before fetching the initial snapshot. The AppHost subscribes before replaying
                // its current snapshots, so the watch establishes a replay point even though the two JSON-RPC
                // calls are not ordered. Version reconciliation retains the newest observed snapshot.
                using var watchCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
                var watchTask = WatchChangesAsync(watchCts.Token);
                List<ResourceSnapshot> snapshots;
                try
                {
                    snapshots = await _connection.GetResourceSnapshotsAsync(includeHidden: true, cancellationToken).ConfigureAwait(false);
                }
                catch
                {
                    // Cleanup failures must not replace the initial GET failure.
                    try
                    {
                        watchCts.Cancel();
                    }
                    catch (Exception)
                    {
                    }
 
                    try
                    {
                        await watchTask.ConfigureAwait(false);
                    }
                    catch (Exception)
                    {
                    }
 
                    throw;
                }
 
                lock (_resourcesLock)
                {
                    foreach (var snapshot in snapshots)
                    {
                        if (!_resources.TryGetValue(snapshot.Name, out var currentSnapshot) ||
                            snapshot.Version > currentSnapshot.Version)
                        {
                            _resources[snapshot.Name] = snapshot;
                        }
                    }
                }
 
                _initialLoadTcs.TrySetResult();
                await watchTask.ConfigureAwait(false);
            }
 
            _updateSignal?.Writer.TryComplete();
        }
        catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
        {
            _initialLoadTcs.TrySetCanceled(cancellationToken);
            _updateSignal?.Writer.TryComplete();
        }
        catch (Exception ex)
        {
            _initialLoadTcs.TrySetException(ex);
            _updateSignal?.Writer.TryComplete(ex);
        }
    }
 
    private async Task WatchChangesAsync(CancellationToken cancellationToken)
    {
        await foreach (var snapshot in _connection.WatchResourceSnapshotsAsync(includeHidden: true, cancellationToken).ConfigureAwait(false))
        {
            long? retainedVersion = null;
            lock (_resourcesLock)
            {
                if (_connection.SupportsResourceSnapshotVersionsV1 &&
                    _resources.TryGetValue(snapshot.Name, out var currentSnapshot) &&
                    snapshot.Version > 0 &&
                    currentSnapshot.Version > 0 &&
                    snapshot.Version < currentSnapshot.Version)
                {
                    // Version 0 is the compatibility value from older AppHosts and cannot establish
                    // ordering. Known lower versions can be stale replay events that arrive after
                    // initial GET/watch reconciliation, so they must not regress state or buffering.
                    retainedVersion = currentSnapshot.Version;
                }
                else
                {
                    _resources[snapshot.Name] = snapshot;
                    var update = new ResourceSnapshotUpdate(++_updateSequence, snapshot);
                    if (_pendingUpdates is not null && !_resyncPending)
                    {
                        if (_pendingUpdates.ContainsKey(snapshot.Name) || _pendingUpdates.Count < UpdateBufferCapacity)
                        {
                            _pendingUpdates[snapshot.Name] = update;
                        }
                        else
                        {
                            // Once the bounded per-resource buffer is full, the current dictionary is the
                            // coalesced representation. The consumer will resynchronize from it rather than
                            // retaining every intermediate transition or stalling the AppHost event stream.
                            _pendingUpdates.Clear();
                            _resyncPending = true;
                        }
                    }
                }
            }
 
            if (retainedVersion is not null)
            {
                _logger.LogDebug(
                    "Ignoring stale snapshot version {IncomingVersion} for resource '{ResourceName}'; retained version is {RetainedVersion}.",
                    snapshot.Version,
                    snapshot.Name,
                    retainedVersion);
                continue;
            }
 
            _updateSignal?.Writer.TryWrite(true);
        }
    }
 
    /// <summary>
    /// Streams updates from the same subscription that maintains the current resource collection.
    /// Callers should first capture the initial state with <see cref="CaptureAllResources"/>.
    /// The update stream can be enumerated only once over the lifetime of the watcher.
    /// </summary>
    public async IAsyncEnumerable<ResourceSnapshotUpdateBatch> WatchResourceSnapshotBatchesAsync(
        long afterSequence,
        [EnumeratorCancellation] CancellationToken cancellationToken = default)
    {
        EnsureInitialLoadComplete();
        var updateSignal = _updateSignal ?? throw new InvalidOperationException("Resource update buffering was not enabled for this watcher.");
        if (Interlocked.Exchange(ref _updateConsumerClaimed, 1) != 0)
        {
            throw new InvalidOperationException("Resource snapshot updates support only one consumer for the lifetime of this watcher.");
        }
 
        await foreach (var _ in updateSignal.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false))
        {
            ResourceSnapshotUpdate[] updates;
            bool isResync;
            lock (_resourcesLock)
            {
                if (_resyncPending)
                {
                    updates = _resources.Values
                        .Select(snapshot => new ResourceSnapshotUpdate(_updateSequence, snapshot))
                        .ToArray();
                    _resyncPending = false;
                    isResync = true;
                }
                else
                {
                    updates = _pendingUpdates!.Values
                        .ToArray();
                    _pendingUpdates.Clear();
                    isResync = false;
                }
            }
 
            var snapshots = isResync
                ? updates
                    .Where(update => update.Sequence > afterSequence)
                    .OrderBy(update => update.Snapshot.Name, StringComparer.OrdinalIgnoreCase)
                    .Select(update => update.Snapshot)
                    .ToArray()
                : updates
                    .Where(update => update.Sequence > afterSequence)
                    .OrderBy(update => update.Sequence)
                    .Select(update => update.Snapshot)
                    .ToArray();
 
            if (snapshots.Length > 0)
            {
                yield return new ResourceSnapshotUpdateBatch(snapshots, isResync);
            }
        }
    }
 
    private void EnsureInitialLoadComplete()
    {
        if (!_initialLoadTcs.Task.IsCompletedSuccessfully)
        {
            throw new InvalidOperationException("Initial resource snapshot load has not completed. Call WaitForInitialLoadAsync first.");
        }
    }
 
    /// <summary>
    /// Gets a resource snapshot by name, or <see langword="null"/> if not found.
    /// </summary>
    public ResourceSnapshot? GetResource(string name)
    {
        EnsureInitialLoadComplete();
        lock (_resourcesLock)
        {
            return _resources.GetValueOrDefault(name);
        }
    }
 
    /// <summary>
    /// Gets all current resource snapshots, using <see cref="IncludeHidden"/> to determine visibility.
    /// </summary>
    /// <returns>Resource snapshots, ordered by name.</returns>
    public IReadOnlyList<ResourceSnapshot> GetResources()
    {
        return GetResources(IncludeHidden);
    }
 
    /// <summary>
    /// Gets all current resource snapshots, including hidden resources.
    /// </summary>
    /// <returns>All resource snapshots, ordered by name.</returns>
    public IReadOnlyList<ResourceSnapshot> GetAllResources()
    {
        return GetResources(includeHidden: true);
    }
 
    /// <summary>
    /// Atomically captures the current resources and the last update represented by that state.
    /// </summary>
    public ResourceSnapshotCapture CaptureAllResources()
    {
        EnsureInitialLoadComplete();
        ResourceSnapshot[] resources;
        long updateSequence;
        lock (_resourcesLock)
        {
            resources = CopyResourcesNoLock();
            updateSequence = _updateSequence;
        }
 
        return new(FilterAndOrderResources(resources, includeHidden: true), updateSequence);
    }
 
    private IReadOnlyList<ResourceSnapshot> GetResources(bool includeHidden)
    {
        EnsureInitialLoadComplete();
 
        ResourceSnapshot[] resources;
        lock (_resourcesLock)
        {
            resources = CopyResourcesNoLock();
        }
 
        return FilterAndOrderResources(resources, includeHidden);
    }
 
    private ResourceSnapshot[] CopyResourcesNoLock() => [.. _resources.Values];
 
    private static IReadOnlyList<ResourceSnapshot> FilterAndOrderResources(
        IEnumerable<ResourceSnapshot> resources,
        bool includeHidden)
    {
        if (!includeHidden)
        {
            resources = resources.Where(snapshot => !ResourceSnapshotMapper.IsHiddenResource(snapshot));
        }
 
        return resources
            .OrderBy(snapshot => snapshot.Name, StringComparer.OrdinalIgnoreCase)
            .ToArray();
    }
 
    public void Dispose()
    {
        _cts.Cancel();
        _cts.Dispose();
    }
 
    internal readonly record struct ResourceSnapshotCapture(
        IReadOnlyList<ResourceSnapshot> Resources,
        long UpdateSequence);
 
    internal readonly record struct ResourceSnapshotUpdateBatch(
        IReadOnlyList<ResourceSnapshot> Snapshots,
        bool IsResync);
 
    private readonly record struct ResourceSnapshotUpdate(
        long Sequence,
        ResourceSnapshot Snapshot);
}