-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathGroundControlConfigurationProvider.cs
More file actions
161 lines (137 loc) · 5.78 KB
/
Copy pathGroundControlConfigurationProvider.cs
File metadata and controls
161 lines (137 loc) · 5.78 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.Diagnostics.HealthChecks;
namespace GroundControl.Link;
/// <summary>
/// A configuration provider that loads configuration from a GroundControl server.
/// </summary>
internal sealed class GroundControlConfigurationProvider : ConfigurationProvider, IDisposable
{
private readonly IGroundControlApiClient _client;
private readonly Lock _applyLock = new();
private string? _appliedEtag;
/// <summary>
/// Initializes a new instance of the <see cref="GroundControlConfigurationProvider"/> class.
/// </summary>
public GroundControlConfigurationProvider(GroundControlStore store, IConfigurationCache cache, IGroundControlApiClient client)
{
Store = store ?? throw new ArgumentNullException(nameof(store));
Cache = cache ?? throw new ArgumentNullException(nameof(cache));
_client = client ?? throw new ArgumentNullException(nameof(client));
Store.OnDataChanged += OnStoreDataChanged;
}
/// <summary>
/// Gets the shared store, discovered via <c>IConfigurationRoot.Providers</c> traversal.
/// </summary>
internal GroundControlStore Store { get; }
/// <summary>
/// Gets the cache instance, discovered via <c>IConfigurationRoot.Providers</c> traversal.
/// </summary>
internal IConfigurationCache Cache { get; }
/// <inheritdoc />
[SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "Startup must not crash the app. Degrade gracefully")]
public override void Load()
{
CachedConfiguration? cached = null;
try
{
cached = Cache.Load();
}
catch
{
// Cache read failure is non-fatal
}
var etag = cached?.ETag;
try
{
var result = _client.FetchConfigAsync(etag, CancellationToken.None).ConfigureAwait(false).GetAwaiter().GetResult();
switch (result.Status)
{
case FetchStatus.Success when result.Config is not null:
Store.Update(new Dictionary<string, ConfigValue>(result.Config, StringComparer.OrdinalIgnoreCase), result.ETag, null);
TrySaveToCache(result.Config, result.ETag, null);
break;
case FetchStatus.NotModified when cached is not null:
ApplyCache(cached);
break;
case FetchStatus.TransientError:
case FetchStatus.AuthenticationError:
case FetchStatus.NotFound:
default:
ApplyCache(cached);
MarkAsUnhealthy(cached, result.Status);
break;
}
}
catch (Exception ex)
{
ApplyCache(cached);
MarkAsUnhealthy(cached, error: ex);
}
SetDataFromStore(Store.GetSnapshot());
}
/// <inheritdoc />
public void Dispose() => Store.OnDataChanged -= OnStoreDataChanged;
private void OnStoreDataChanged()
{
// Gate on the snapshot's etag (server-assigned snapshot version) so that a replay
// of the already-applied snapshot — e.g. the initial frame an SSE stream delivers
// on connect, right after Load() just fetched the same snapshot via REST — does
// not fire a spurious OnReload. Without this gate, a consumer that registered a
// reload callback between Load() and the SSE replay arriving would be notified
// with stale Data: the callback fires on the replay event, but Data still reflects
// the REST-fetched snapshot which is identical to the replay, not any later change.
lock (_applyLock)
{
var snapshot = Store.GetSnapshot();
if (string.Equals(snapshot.ETag, _appliedEtag, StringComparison.Ordinal))
{
return;
}
_appliedEtag = snapshot.ETag;
SetDataFromStore(snapshot);
}
OnReload();
}
private void SetDataFromStore(StoreSnapshot snapshot)
{
var data = new Dictionary<string, string?>(snapshot.Data.Count, StringComparer.OrdinalIgnoreCase);
foreach (var (key, value) in snapshot.Data)
{
data[key] = value.Value;
}
Data = data;
}
private void ApplyCache(CachedConfiguration? cache)
{
if (cache is null)
{
return;
}
Store.Update(new Dictionary<string, ConfigValue>(cache.Entries, StringComparer.OrdinalIgnoreCase), cache.ETag, cache.LastEventId);
}
private void MarkAsUnhealthy(CachedConfiguration? cached, FetchStatus? fetchStatus = null, Exception? error = null)
{
var reason = fetchStatus switch
{
FetchStatus.AuthenticationError => "Authentication failed (401/403). Check ClientId and ClientSecret.",
FetchStatus.NotFound => "No active snapshot found on the server (404).",
FetchStatus.TransientError => "Server returned a transient error.",
_ when error is not null => error.Message,
_ => null
};
Store.SetHealth(cached is not null ? HealthStatus.Degraded : HealthStatus.Unhealthy, reason, error);
}
[SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "Cache save is best-effort; failures are non-fatal")]
private void TrySaveToCache(IReadOnlyDictionary<string, ConfigValue> data, string? etag, string? lastEventId)
{
try
{
var entries = new Dictionary<string, ConfigValue>(data, StringComparer.OrdinalIgnoreCase);
Cache.Save(new CachedConfiguration { Entries = entries, ETag = etag, LastEventId = lastEventId });
}
catch
{
// Best-effort
}
}
}