File: Model\BrowserStorage\LocalStorageSetting.cs
Web Access
Project: src\src\Aspire.Dashboard\Aspire.Dashboard.csproj (Aspire.Dashboard)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
namespace Aspire.Dashboard.Model.BrowserStorage;
 
/// <summary>
/// Keeps one setting that describes the application, such as resource tags, in memory for a circuit and saves it to
/// the browser's local storage.
/// </summary>
/// <remarks>
/// <para>
/// Every tab of the dashboard has its own circuit, and so its own copy of the setting, but they share local storage.
/// Writing a tab's copy would replace changes another tab saved since this tab read the setting. Instead, each change
/// is a function that's applied to the in-memory value right away and queued. Writes read the latest stored value,
/// apply the queued changes to it and save the result, which then becomes the in-memory value, so a tab also picks up
/// what other tabs saved. Changes must be idempotent, such as "tag api with backend", because they're applied again
/// to every value read before they're saved.
/// </para>
/// <para>
/// When storage can't be read, for example while the circuit is disconnected, nothing is written: writing then would
/// replace the stored value with one missing everything it contained. Writes are retried a few times with increasing
/// delays, and again with the next change or when a view loads the setting. A stored value that can't be
/// deserialized, for example because it was saved by another version of the dashboard, can't become readable, so it's
/// copied to <c>{key}_Unreadable</c>, unless a value was already copied there, and then replaced, rather than
/// preventing the setting from being saved again.
/// </para>
/// <para>
/// Settings are stored unprotected. Protected values are encrypted with the dashboard's data protection key, which
/// changes between Aspire versions and on every run of the standalone dashboard, so protected settings would be lost
/// regularly. The settings stored here are names chosen by the user or reported by the app, not secrets.
/// </para>
/// <para>
/// Changes can happen on any thread, including resource subscription threads, so writes don't wait for the browser.
/// Writes run one at a time, each saving every change queued when it starts, and saves of the same setting from
/// different tabs run one at a time too; see <see cref="LocalStorageLocks"/>.
/// </para>
/// </remarks>
/// <typeparam name="TValue">The stored value. It must be registered with the dashboard's JSON serializer context.</typeparam>
internal sealed class LocalStorageSetting<TValue> where TValue : class
{
    // Retries cover a reconnecting circuit: delays of 1, 2, 4 and 8 seconds wait about 15 seconds in all.
    private const int MaxWriteAttempts = 5;
    private static readonly TimeSpan s_firstRetryDelay = TimeSpan.FromSeconds(1);
    private static readonly TimeSpan s_saveLockTimeout = TimeSpan.FromSeconds(10);
 
    private readonly ILocalStorage _localStorage;
    private readonly LocalStorageLocks _locks;
    private readonly TimeProvider _timeProvider;
    private readonly ILogger _logger;
    private readonly Func<TValue?, TValue> _normalize;
    private readonly Func<TValue, TValue, bool> _equals;
    private readonly Action _onChanged;
    private readonly Lock _lock = new();
    private readonly List<Func<TValue, bool>> _pendingChanges = [];
    private TValue _value;
    private Task? _loadTask;
    private bool _isWriting;
 
    // Incremented after each save. A load that read storage before a save completed has an older value than the one
    // the save adopted, so it must not replace it.
    private long _saveCount;
 
    /// <param name="localStorage">The browser's local storage.</param>
    /// <param name="locks">Makes saves of the setting from different tabs run one at a time.</param>
    /// <param name="timeProvider">Times the delays between save retries.</param>
    /// <param name="key">The local storage key.</param>
    /// <param name="logger">Logs values that can't be read or saved.</param>
    /// <param name="normalize">
    /// Creates a value that changes can be applied to from a stored value, or from <see langword="null"/> when nothing
    /// is stored. Stored values can be edited in the browser, so this also drops invalid entries.
    /// </param>
    /// <param name="equals">Returns whether two normalized values have the same content, to only notify real changes.</param>
    /// <param name="onChanged">Invoked, on any thread, after the in-memory value changes.</param>
    public LocalStorageSetting(ILocalStorage localStorage, LocalStorageLocks locks, TimeProvider timeProvider, string key, ILogger logger, Func<TValue?, TValue> normalize, Func<TValue, TValue, bool> equals, Action onChanged)
    {
        _localStorage = localStorage;
        _locks = locks;
        _timeProvider = timeProvider;
        Key = key;
        _logger = logger;
        _normalize = normalize;
        _equals = equals;
        _onChanged = onChanged;
        _value = normalize(null);
    }
 
    public string Key { get; }
 
    /// <summary>
    /// Reads the in-memory value. <paramref name="read"/> must not keep a reference to the value or change it.
    /// </summary>
    public TResult Read<TResult>(Func<TValue, TResult> read)
    {
        lock (_lock)
        {
            return read(_value);
        }
    }
 
    /// <summary>
    /// Reads the stored value into memory, once per circuit, or again after storage couldn't be read. Also saves
    /// changes that earlier writes couldn't save.
    /// </summary>
    public Task EnsureLoadedAsync()
    {
        TaskCompletionSource? loaded = null;
        Task loadTask;
        lock (_lock)
        {
            if (_loadTask is null)
            {
                // Continuations can run inline: the task is completed outside the lock, and when storage answers
                // synchronously the task is already complete when it's returned, so callers don't yield.
                loaded = new TaskCompletionSource();
                _loadTask = loaded.Task;
            }
            loadTask = _loadTask;
        }
 
        // Started outside the lock: with storage that completes synchronously, loading would otherwise raise
        // change notifications while holding it.
        if (loaded is not null)
        {
            _ = LoadAsync(loaded);
        }
        else if (loadTask.IsCompleted)
        {
            StartWritingIfPending();
        }
 
        return loadTask;
    }
 
    /// <summary>
    /// Applies a change to the in-memory value and saves it in the background.
    /// </summary>
    /// <param name="change">Changes a value and returns whether it changed. It must be idempotent.</param>
    /// <returns><see langword="true"/> when the in-memory value changed.</returns>
    public bool Update(Func<TValue, bool> change)
    {
        bool startWriting;
        lock (_lock)
        {
            if (!change(_value))
            {
                return false;
            }
 
            _pendingChanges.Add(change);
            startWriting = !_isWriting;
            _isWriting = true;
        }
 
        _onChanged();
        if (startWriting)
        {
            _ = WriteAsync();
        }
        return true;
    }
 
    private async Task LoadAsync(TaskCompletionSource loaded)
    {
        try
        {
            long saveCount;
            lock (_lock)
            {
                saveCount = _saveCount;
            }
 
            var result = await _localStorage.GetUnprotectedAsync<TValue>(Key).ConfigureAwait(false);
            if (result.ReadFailed)
            {
                // Load again when another view asks, for example after the circuit reconnects.
                lock (_lock)
                {
                    _loadTask = null;
                }
                return;
            }
            if (result.InvalidValue)
            {
                _logger.LogWarning("'{Key}' in browser local storage can't be read by this version of the dashboard. It's replaced with the next change and kept as '{Key}_Unreadable'.", Key, Key);
            }
 
            Adopt(result.Success ? result.Value : null, savedChangeCount: 0, loadedAfterSaveCount: saveCount);
        }
        catch (Exception ex)
        {
            // The setting stays as it is in memory. Not retried: an error here comes from the stored value, which
            // would fail the same way again.
            _logger.LogWarning(ex, "Unable to load '{Key}' from browser local storage.", Key);
        }
        finally
        {
            loaded.SetResult();
        }
 
        // Changes made while the stored value couldn't be read are saved now that it can be.
        StartWritingIfPending();
    }
 
    private void StartWritingIfPending()
    {
        bool startWriting;
        lock (_lock)
        {
            startWriting = _pendingChanges.Count > 0 && !_isWriting;
            _isWriting |= startWriting;
        }
        if (startWriting)
        {
            _ = WriteAsync();
        }
    }
 
    private async Task WriteAsync()
    {
        var failedAttempts = 0;
        while (true)
        {
            Func<TValue, bool>[] changes;
            lock (_lock)
            {
                if (_pendingChanges.Count == 0)
                {
                    _isWriting = false;
                    return;
                }
                changes = [.. _pendingChanges];
            }
 
            Exception? error = null;
            try
            {
                if (await TryWriteAsync(changes).ConfigureAwait(false))
                {
                    failedAttempts = 0;
                    continue;
                }
            }
            catch (Exception ex)
            {
                // Includes a disconnected circuit, which can reconnect.
                error = ex;
            }
 
            failedAttempts++;
            if (failedAttempts == MaxWriteAttempts)
            {
                // Settings are a convenience: changes stay applied for the rest of the circuit, and are saved with the
                // next change or when a view loads the setting, if storage can be used again by then.
                _logger.LogWarning(error, "Unable to save '{Key}' to browser local storage.", Key);
                lock (_lock)
                {
                    _isWriting = false;
                }
                return;
            }
 
            await Task.Delay(s_firstRetryDelay * Math.Pow(2, failedAttempts - 1), _timeProvider).ConfigureAwait(false);
        }
    }
 
    /// <returns><see langword="false"/> when storage couldn't be read, so nothing was written.</returns>
    private async Task<bool> TryWriteAsync(Func<TValue, bool>[] changes)
    {
        // Another tab saving the same setting between this read and write would have its change replaced, so the whole
        // save holds the setting's lock. A browser that stops answering would otherwise hold up saves in other tabs
        // until its JavaScript call times out; giving up is retried like storage that can't be read.
        var saveLock = _locks.GetLock(Key);
        if (!await saveLock.WaitAsync(s_saveLockTimeout).ConfigureAwait(false))
        {
            return false;
        }
 
        try
        {
            return await TryWriteLockedAsync(changes).ConfigureAwait(false);
        }
        finally
        {
            saveLock.Release();
        }
    }
 
    private async Task<bool> TryWriteLockedAsync(Func<TValue, bool>[] changes)
    {
        var result = await _localStorage.GetUnprotectedAsync<TValue>(Key).ConfigureAwait(false);
        if (result.ReadFailed)
        {
            return false;
        }
        if (result.InvalidValue)
        {
            // Keep the value for the version of the dashboard that saved it, or for someone to recover it by hand.
            // Only the first unreadable
            // value is kept: dashboard versions that alternate on the same app would otherwise keep replacing each
            // other's backup, until nothing of the first value is left.
            var backupKey = $"{Key}_Unreadable";
            var backup = await _localStorage.GetRawUnprotectedAsync(backupKey).ConfigureAwait(false);
            if (backup.ReadFailed)
            {
                return false;
            }
            if (!backup.Success)
            {
                // Copied as text, so a value that isn't valid JSON, such as one cut short, is kept too.
                var unreadable = await _localStorage.GetRawUnprotectedAsync(Key).ConfigureAwait(false);
                if (unreadable.ReadFailed)
                {
                    return false;
                }
                if (unreadable.Success)
                {
                    await _localStorage.SetRawUnprotectedAsync(backupKey, unreadable.Value).ConfigureAwait(false);
                }
            }
        }
 
        var value = _normalize(result.Success ? result.Value : null);
        foreach (var change in changes)
        {
            change(value);
        }
 
        await _localStorage.SetUnprotectedAsync(Key, value).ConfigureAwait(false);
        Adopt(value, changes.Length, loadedAfterSaveCount: null);
        return true;
    }
 
    /// <summary>
    /// Makes a value read from storage, or just saved, the in-memory value, with changes that aren't saved yet applied.
    /// </summary>
    /// <param name="stored">The stored value, or <see langword="null"/> when nothing is stored.</param>
    /// <param name="savedChangeCount">The number of queued changes that <paramref name="stored"/> includes.</param>
    /// <param name="loadedAfterSaveCount">For a load, the number of saves completed when it read storage.</param>
    private void Adopt(TValue? stored, int savedChangeCount, long? loadedAfterSaveCount)
    {
        bool changed;
        lock (_lock)
        {
            if (loadedAfterSaveCount is { } count && count != _saveCount)
            {
                // A save completed after this load read storage, and adopted a value read later.
                return;
            }
            if (loadedAfterSaveCount is null)
            {
                _saveCount++;
            }
 
            // Only the writer removes changes, and they're always the oldest ones, so the first ones are those it saved.
            _pendingChanges.RemoveRange(0, savedChangeCount);
            var value = _normalize(stored);
            foreach (var change in _pendingChanges)
            {
                change(value);
            }
 
            changed = !_equals(_value, value);
            _value = value;
        }
 
        if (changed)
        {
            _onChanged();
        }
    }
}