// Copyright Epic Games, Inc. All Rights Reserved.
using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Threading;
using System.Threading.Tasks;
using EpicGames.Core;
using EpicGames.Horde.Users;
using EpicGames.Redis;
using HordeServer.Acls;
using HordeServer.Plugins;
using HordeServer.Server;
using HordeServer.Users;
using HordeServer.Utilities;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Primitives;
using ProtoBuf;
using StackExchange.Redis;
namespace HordeServer.Configuration
{
using JsonObject = System.Text.Json.Nodes.JsonObject;
///
/// Service which processes runtime configuration data.
///
public sealed class ConfigService : IConfigService, IOptionsFactory, IOptionsChangeTokenSource, IHostedService, IAsyncDisposable
{
// Index to all current config files
[ProtoContract]
class ConfigSnapshot
{
[ProtoMember(1)]
public byte[] Data { get; set; } = null!;
[ProtoMember(4)]
public Dictionary Dependencies { get; set; } = new Dictionary();
[ProtoMember(5)]
public string ServerVersion { get; set; } = String.Empty;
}
// Implements a token for config change notifications
class ChangeToken : IChangeToken
{
class Registration : IDisposable
{
readonly List _registrations;
readonly Action _callback;
readonly object? _state;
public Registration(List registrations, Action callback, object? state)
{
_registrations = registrations;
_callback = callback;
_state = state;
lock (registrations)
{
registrations.Add(this);
}
}
public void Dispose()
{
lock (_registrations)
{
_registrations.Remove(this);
}
}
public void Trigger() => _callback(_state);
}
readonly List _registrations = new List();
public bool ActiveChangeCallbacks => true;
public bool HasChanged { get; private set; }
public void TriggerChange()
{
HasChanged = true;
Registration[] registrations;
lock (_registrations)
{
registrations = _registrations.ToArray();
}
foreach (Registration registration in registrations)
{
registration.Trigger();
}
HasChanged = false;
}
public IDisposable RegisterChangeCallback(Action callback, object? state)
{
return new Registration(_registrations, callback, state);
}
}
// The current config
record class ConfigState(IoHash Hash, GlobalConfig GlobalConfig);
readonly IRedisService _redisService;
readonly ServerSettings _serverSettings;
readonly Dictionary _sources;
readonly IPluginCollection _pluginCollection;
readonly JsonSerializerOptions _jsonOptions;
readonly RedisKey _snapshotKey = "config-v2";
readonly IEnumerable _defaultAclModifiers;
readonly IHealthMonitor _health;
readonly ILogger _logger;
readonly ITicker _ticker;
readonly TimeSpan _tickInterval = TimeSpan.FromMinutes(1.0);
readonly RedisChannel _updateChannel = RedisChannel.Literal("config-update-v2");
readonly BackgroundTask _updateTask;
Task _stateTask;
readonly ChangeToken _changeToken = new ChangeToken();
///
string IOptionsChangeTokenSource.Name => String.Empty;
///
/// Event for notifications that the config has been updated
///
public event Action? OnConfigUpdate;
///
/// Constructor
///
public ConfigService(IRedisService redisService, IOptions serverSettings, IEnumerable sources, IPluginCollection pluginCollection, IEnumerable aclModifiers, IClock clock, IHealthMonitor health, ILogger logger)
{
_redisService = redisService;
_serverSettings = serverSettings.Value;
_sources = sources.ToDictionary(x => x.Scheme, x => x, StringComparer.OrdinalIgnoreCase);
_defaultAclModifiers = aclModifiers;
_pluginCollection = pluginCollection;
_health = health;
_health.SetName("GlobalConfig");
_logger = logger;
_jsonOptions = new JsonSerializerOptions();
JsonUtils.ConfigureJsonSerializer(_jsonOptions);
PluginConfigCollectionConverter pluginConfigConverter = new PluginConfigCollectionConverter();
foreach (ILoadedPlugin plugin in pluginCollection.LoadedPlugins)
{
if (plugin.GlobalConfigType != null)
{
pluginConfigConverter.NameToType[plugin.Name] = plugin.GlobalConfigType;
}
}
_jsonOptions.Converters.Add(pluginConfigConverter);
_ticker = clock.AddSharedTicker(_tickInterval, TickSharedAsync, logger);
_updateTask = new BackgroundTask(WaitForUpdatesAsync);
_stateTask = Task.Run(() => GetStartupStateAsync());
}
///
public async ValueTask DisposeAsync()
{
await _stateTask;
await _updateTask.DisposeAsync();
await _ticker.DisposeAsync();
}
///
/// Wait for the initial config state to be read.
///
public async Task WaitForInitialConfigAsync(CancellationToken cancellationToken = default)
{
ConfigState state = await _stateTask.WaitAsync(cancellationToken);
return state.GlobalConfig;
}
class OverrideConfigFile : IConfigFile
{
readonly byte[] _data;
public Uri Uri { get; }
public string Revision => "New";
public IUser? Author => null;
public bool WasRead { get; private set; }
public OverrideConfigFile(Uri uri, byte[] data)
{
Uri = uri;
_data = data;
}
public ValueTask> ReadAsync(CancellationToken cancellationToken)
{
WasRead = true;
return new ValueTask>(_data);
}
}
class OverrideConfigSource : IConfigSource
{
readonly IConfigSource _inner;
readonly Dictionary _overrides;
public string Scheme => _inner.Scheme;
public TimeSpan UpdateInterval => TimeSpan.FromSeconds(1.0);
public OverrideConfigSource(IConfigSource inner, Dictionary overrides)
{
_inner = inner;
_overrides = overrides;
}
public void Add(OverrideConfigFile file) => _overrides.Add(file.Uri, file);
public async Task GetFilesAsync(Uri[] uris, CancellationToken cancellationToken)
{
IConfigFile[] files = new IConfigFile[uris.Length];
for (int idx = 0; idx < uris.Length; idx++)
{
IConfigFile? file;
if (_overrides.TryGetValue(uris[idx], out OverrideConfigFile? overrideFile))
{
file = overrideFile;
}
else
{
file = (await _inner.GetFilesAsync(new[] { uris[idx] }, cancellationToken))[0];
}
files[idx] = file;
}
return files;
}
///
public Task GetUpdateInfoAsync(IReadOnlyDictionary files, IReadOnlyDictionary? prevFiles, ConfigUpdateInfo updateInfo, CancellationToken cancellationToken)
=> Task.CompletedTask;
}
ConfigContext CreateConfigContext(IReadOnlyDictionary sources, ILogger logger)
{
ConfigContext context = new ConfigContext(_jsonOptions, sources, logger);
context.MacroScopes.Add(new Dictionary { ["HordeDir"] = ServerApp.AppDir.FullName });
return context;
}
///
/// Validate a new set of config files. Parses and runs PostLoad methods on them.
///
public async Task ValidateAsync(Dictionary files, CancellationToken cancellationToken)
{
Dictionary overrideFiles = new Dictionary();
foreach ((Uri uri, byte[] data) in files)
{
overrideFiles[uri] = new OverrideConfigFile(uri, data);
}
Dictionary overrideSources = new Dictionary(_sources.Count, _sources.Comparer);
foreach ((string schema, IConfigSource source) in _sources)
{
overrideSources.Add(schema, new OverrideConfigSource(source, overrideFiles));
}
ConfigContext context = CreateConfigContext(overrideSources, NullLogger.Instance);
try
{
GlobalConfig globalConfig = await ReadGlobalConfigAsync(context, cancellationToken);
await globalConfig.PostLoadAsync(_serverSettings, _pluginCollection.LoadedPlugins, _defaultAclModifiers);
foreach (OverrideConfigFile file in overrideFiles.Values)
{
if (!file.WasRead)
{
return $"File {file.Uri} was not read by server";
}
}
return null;
}
catch (Exception ex)
{
StringBuilder message = new StringBuilder(ex.Message);
message.Append("\n\nLocation:\n");
foreach (IConfigFile include in context.IncludeStack)
{
string path = include.GetUserFormattedPath();
message.Append($" {path}\n");
}
if (ex is not ConfigException && ex is not JsonException)
{
message.Append($"\n\nTrace:\n{ex.StackTrace}");
_logger.LogWarning(ex, "Unhandled exception while validating config files: {Message}", ex.Message);
}
return message.ToString();
}
}
///
public async Task StartAsync(CancellationToken cancellationToken)
{
// Wait for the initial config update to complete
await _stateTask;
if (_serverSettings.IsRunModeActive(RunMode.Worker))
{
await _ticker.StartAsync();
}
_updateTask.Start();
}
///
public async Task StopAsync(CancellationToken cancellationToken)
{
await _updateTask.StopAsync(cancellationToken);
if (_serverSettings.IsRunModeActive(RunMode.Worker))
{
await _ticker.StopAsync();
}
}
///
/// Accessor for tests to update the global config
///
/// New config value
public void OverrideConfig(GlobalConfig globalConfig)
{
Set(IoHash.Zero, globalConfig);
}
///
/// Updates the current config
///
/// Hash for the config data
/// New config value
void Set(IoHash hash, GlobalConfig globalConfig)
{
// Set the new state
_stateTask = Task.FromResult(new ConfigState(hash, globalConfig));
// Notify any watchers of the new config object
_changeToken.TriggerChange();
}
///
public GlobalConfig Create(string name)
{
#pragma warning disable VSTHRD002 // Synchronous wait
return _stateTask.Result.GlobalConfig;
#pragma warning restore VSTHRD002 // Synchronous wait
}
///
public IChangeToken GetChangeToken() => _changeToken;
async Task GetStartupStateAsync()
{
try
{
bool forceConfigUpdate = _serverSettings.ForceConfigUpdateOnStartup;
if (forceConfigUpdate && _redisService.ReadOnlyMode)
{
_logger.LogInformation("Ignoring flag to force config update on startup due to Redis configured in read-only mode.");
forceConfigUpdate = false;
}
ReadOnlyMemory data = ReadOnlyMemory.Empty;
if (!forceConfigUpdate)
{
data = await ReadSnapshotDataAsync();
}
if (data.Length == 0)
{
ConfigSnapshot snapshot = await CreateSnapshotAsync(CancellationToken.None);
await WriteSnapshotAsync(snapshot);
data = SerializeSnapshot(snapshot);
}
ConfigState state = new ConfigState(IoHash.Compute(data.Span), await CreateGlobalConfigAsync(data));
await _health.UpdateAsync(HealthStatus.Healthy);
return state;
}
catch (Exception ex)
{
await _health.UpdateAsync(HealthStatus.Unhealthy, ex.Message);
throw;
}
}
async ValueTask TickSharedAsync(CancellationToken cancellationToken)
{
// Read a new copy of the current snapshot in the context of being elected to perform updates. This
// avoids any latency with receiving notifications on the pub/sub channel about changes.
ReadOnlyMemory initialData = await ReadSnapshotDataAsync();
// Print info about the initial snapshot
ConfigSnapshot? snapshot = null;
if (initialData.Length > 0)
{
snapshot = DeserializeSnapshot(initialData);
_logger.LogDebug("Initial snapshot for update: {@Info}", GetSnapshotInfo(initialData.Span, snapshot));
}
// Update the snapshot until we're asked to stop
while (!cancellationToken.IsCancellationRequested)
{
if (snapshot == null || await IsOutOfDateAsync(snapshot, cancellationToken))
{
snapshot = await CreateSnapshotGuardedAsync(snapshot?.Dependencies, cancellationToken);
if (snapshot != null)
{
try
{
await WriteSnapshotAsync(snapshot);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error while updating config: {Message}", ex.Message);
}
}
}
TimeSpan tickInterval = GetConfigUpdateInterval(snapshot);
await Task.Delay(tickInterval, cancellationToken);
}
}
async Task WaitForUpdatesAsync(CancellationToken cancellationToken)
{
AsyncEvent updateEvent = new AsyncEvent();
await using (RedisSubscription subscription = await _redisService.SubscribeAsync(_updateChannel, _ => updateEvent.Pulse()))
{
Task cancellationTask = Task.Delay(-1, cancellationToken);
while (!cancellationToken.IsCancellationRequested)
{
Task updateTask = updateEvent.Task;
try
{
RedisValue value = await _redisService.GetDatabase().StringGetAsync(_snapshotKey);
if (!value.IsNullOrEmpty)
{
await UpdateConfigObjectAsync(value);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Exception while updating config from Redis: {Message}", ex.Message);
await Task.Delay(TimeSpan.FromMinutes(1.0), cancellationToken);
continue;
}
await await Task.WhenAny(updateTask, cancellationTask);
}
}
}
///
/// Create a new snapshot object, catching any exceptions and sending notifications
///
/// Previous snapshot data
/// Cancellation token for the operation
/// New config snapshot
async Task CreateSnapshotGuardedAsync(IReadOnlyDictionary? prevFiles, CancellationToken cancellationToken)
{
try
{
ConfigSnapshot snapshot = await CreateSnapshotAsync(cancellationToken);
await _health.UpdateAsync(HealthStatus.Healthy);
await NotifyConfigUpdateAsync(snapshot.Dependencies, prevFiles, null, cancellationToken);
return snapshot;
}
catch (ConfigException ex)
{
_logger.LogError(ex, "Exception while updating config: {Message}", ex.Message);
await _health.UpdateAsync(HealthStatus.Unhealthy, ex.Message);
await NotifyConfigUpdateAsync(ex.GetContext().Files.ToDictionary(x => x.Key, x => x.Value.Revision), prevFiles, ex, cancellationToken);
return null;
}
}
async Task NotifyConfigUpdateAsync(IReadOnlyDictionary files, IReadOnlyDictionary? prevFiles, Exception? exception, CancellationToken cancellationToken)
{
ConfigUpdateInfo info = new ConfigUpdateInfo(new List(), new HashSet(), exception);
foreach (IConfigSource source in _sources.Values)
{
await source.GetUpdateInfoAsync(files, prevFiles, info, cancellationToken);
}
OnConfigUpdate?.Invoke(info);
}
internal Uri GetGlobalConfigUri()
{
return GetGlobalConfigUri(_serverSettings.ConfigPath, ServerApp.ConfigDir.FullName);
}
///
/// Gets the path to the root config file
///
internal static Uri GetGlobalConfigUri(string configPath, string defaultConfigDir)
{
bool isPerforcePath = configPath.StartsWith("//", StringComparison.Ordinal);
if (isPerforcePath)
{
return ConfigNode.CombinePaths(new Uri(FileReference.Combine(new DirectoryReference(defaultConfigDir), "_").FullName), configPath);
}
Uri? uri;
if (Uri.TryCreate(configPath, UriKind.Absolute, out uri))
{
return uri;
}
// Path is relative
FileReference fileReference = FileReference.Combine(new DirectoryReference(defaultConfigDir), configPath);
return new UriBuilder("file", String.Empty) { Path = fileReference.FullName }.Uri;
}
///
/// Get the appropriate update interval for checking of new config updates
///
TimeSpan GetConfigUpdateInterval(ConfigSnapshot? snapshot)
{
TimeSpan interval = TimeSpan.FromSeconds(5.0);
foreach (Uri sourceUri in GetConfigSourceUris(snapshot))
{
if (_sources.TryGetValue(sourceUri.Scheme, out IConfigSource? configSource))
{
TimeSpan sourceInterval = configSource.UpdateInterval;
if (sourceInterval > interval)
{
interval = sourceInterval;
}
}
}
return interval;
}
///
/// Enumerates all the known URIs for reading the given config snapshot.
///
IEnumerable GetConfigSourceUris(ConfigSnapshot? snapshot)
{
if (snapshot == null)
{
return new[] { GetGlobalConfigUri() };
}
else
{
return snapshot.Dependencies.Keys;
}
}
///
/// Create a new snapshot object
///
/// Cancellation token for the operation
/// New config snapshot
async Task CreateSnapshotAsync(CancellationToken cancellationToken)
{
// Read the config files
ConfigContext context = CreateConfigContext(_sources, _logger);
try
{
// Bind it to the target object
GlobalConfig globalConfig = await ReadGlobalConfigAsync(context, cancellationToken);
if (globalConfig.VersionEnum < ConfigVersion.Latest)
{
List message = new List();
message.Add($"Support for the following features will be removed in an upcoming release:");
message.Add("");
if (globalConfig.VersionEnum < ConfigVersion.PoolsInConfigFiles)
{
message.Add($"- v{(int)ConfigVersion.PoolsInConfigFiles}: Pools should now be configured through the globals.json file rather than REST API or database. The /api/v1/server/migrate/pool-config endpoint will transcribe your configured pools into JSON.");
}
message.Add("");
message.Add($"Please migrate your installation and update the 'Version' property in globals.json to {(int)ConfigVersion.Latest}");
_logger.LogWarning("Global config file is using old version number ({Version}<{LatestVersion})\n\n{DeprecatedFeaturesMessage}\n", globalConfig.Version, (int)ConfigVersion.Latest, String.Join("\n", message));
}
// Create the snapshot data, with the config serialized back out to a byte array
ConfigSnapshot snapshot = new ConfigSnapshot();
snapshot.ServerVersion = ServerApp.Version.ToString();
snapshot.Data = JsonSerializer.SerializeToUtf8Bytes(globalConfig, context.JsonOptions);
// Save all the dependencies
foreach ((Uri depUri, IConfigFile depFile) in context.Files)
{
snapshot.Dependencies.Add(depUri, depFile.Revision);
}
// Execute a PostLoad before returning so we can validate that everything is valid
await globalConfig.PostLoadAsync(_serverSettings, _pluginCollection.LoadedPlugins, _defaultAclModifiers);
return snapshot;
}
catch (Exception ex) when (ex is not ConfigException)
{
throw new ConfigException(context, ex.Message, ex);
}
}
async Task ReadGlobalConfigAsync(ConfigContext context, CancellationToken cancellationToken)
{
// Read the new config in
Uri globalConfigUri = GetGlobalConfigUri();
// Generate the schema for the preprocessor
ObjectConfigNode configNode = new ObjectConfigNode(typeof(GlobalConfig));
ObjectConfigNode pluginsObj = new ObjectConfigNode(false, false);
configNode.Properties["Plugins"] = pluginsObj;
foreach (ILoadedPlugin plugin in _pluginCollection.LoadedPlugins)
{
if (plugin.GlobalConfigType != null)
{
pluginsObj.Properties.Add(plugin.Name.ToString(), new ObjectConfigNode(plugin.GlobalConfigType));
}
}
// Copy the schema from any remapped nodes to the source node
foreach (KeyValuePair remapConfigPair in s_remapConfigValues)
{
if (configNode.TryGetChildNode(remapConfigPair.Value, out ConfigNode? node))
{
configNode.AddChildNode(remapConfigPair.Key, node);
}
}
// Preprocess the file
JsonObject configObj = await context.PreprocessFileAsync(globalConfigUri, configNode, cancellationToken);
// Copy the preprocessed data into the right location
foreach ((string source, string target) in s_remapConfigValues)
{
CopyJsonNode(configObj, source, target);
}
// Bind it to the target object
return JsonSerializer.Deserialize(configObj, _jsonOptions)!;
}
static readonly KeyValuePair[] s_remapConfigValues = new[]
{
// Analytics
KeyValuePair.Create("TelemetryStores", "Plugins.Analytics.Stores"),
// Build
KeyValuePair.Create("PerforceClusters", "Plugins.Build.PerforceClusters"),
KeyValuePair.Create("Devices", "Plugins.Build.Devices"),
KeyValuePair.Create("MaxConformCount", "Plugins.Build.MaxConformCount"),
KeyValuePair.Create("AgentShutdownIfDisabledGracePeriod", "Plugins.Build.AgentShutdownIfDisabledGracePeriod"),
KeyValuePair.Create("ArtifactTypes", "Plugins.Build.ArtifactTypes"),
KeyValuePair.Create("Projects", "Plugins.Build.Projects"),
KeyValuePair.Create("IssueFixedTag", "Plugins.Build.IssueFixedTag"),
// Compute
KeyValuePair.Create("Rates", "Plugins.Compute.Rates"),
KeyValuePair.Create("Compute", "Plugins.Compute.Clusters"),
KeyValuePair.Create("Pools", "Plugins.Compute.Pools"),
KeyValuePair.Create("Software", "Plugins.Compute.Software"),
KeyValuePair.Create("Networks", "Plugins.Compute.Network"),
// Secrets
KeyValuePair.Create("Secrets", "Plugins.Secrets.Secrets"),
// Storage
KeyValuePair.Create("Storage", "Plugins.Storage"),
// Tools
KeyValuePair.Create("Tools", "Plugins.Tools.Tools")
};
static void CopyJsonNode(JsonObject rootObj, string sourcePath, string targetPath)
{
JsonNode? sourceNode = rootObj;
string[] sourceFragments = sourcePath.Split('.');
foreach (string sourceFragment in sourceFragments)
{
sourceNode = sourceNode[sourceFragment];
if (sourceNode == null)
{
return;
}
}
JsonObject? targetObj = rootObj;
string[] targetFragments = targetPath.Split('.');
for (int idx = 0; idx < targetFragments.Length - 1; idx++)
{
string targetFragment = targetFragments[idx];
JsonObject? nextObj = targetObj[targetFragment] as JsonObject;
if (nextObj == null)
{
nextObj = new JsonObject();
targetObj[targetFragment] = nextObj;
}
targetObj = nextObj;
}
targetObj[targetFragments[^1]] = sourceNode.DeepClone();
}
internal byte[] Serialize(GlobalConfig config)
{
return JsonSerializer.SerializeToUtf8Bytes(config, _jsonOptions);
}
internal GlobalConfig? Deserialize(byte[] data, bool withData)
{
JsonSerializerOptions options = new JsonSerializerOptions(_jsonOptions);
options.Converters.Add(new ConfigResourceSerializer(withData));
return JsonSerializer.Deserialize(data, options);
}
///
/// Writes information about the current snapshot to the logger
///
/// Data for the snapshot
/// Snapshot to log information on
static object GetSnapshotInfo(ReadOnlySpan span, ConfigSnapshot snapshot)
{
return new { Revision = IoHash.Compute(span).ToString(), ServerVersion = snapshot.ServerVersion.ToString(), Dependencies = snapshot.Dependencies.ToArray() };
}
///
/// Checks if the given snapshot is out of date
///
/// The current config snapshot
/// Cancellation token for the operation
/// True if the snapshot is out of date
async Task IsOutOfDateAsync(ConfigSnapshot snapshot, CancellationToken cancellationToken)
{
try
{
// Always re-read the config file when switching server versions
string newServerVersion = ServerApp.Version.ToString();
if (!snapshot.ServerVersion.Equals(newServerVersion, StringComparison.Ordinal))
{
_logger.LogInformation("Config is out of date (server version {OldVersion} -> {NewVersion})", snapshot.ServerVersion, newServerVersion);
return true;
}
// Group the dependencies by scheme in order to allow the source to batch-query them
foreach (IGrouping> group in snapshot.Dependencies.GroupBy(x => x.Key.Scheme))
{
KeyValuePair[] pairs = group.ToArray();
IConfigSource? source;
if (!_sources.TryGetValue(group.Key, out source))
{
_logger.LogInformation("Config is out of date (missing source '{Source}')", group.Key);
return true;
}
IConfigFile[] files = await source.GetFilesAsync(pairs.ConvertAll(x => x.Key), cancellationToken);
for (int idx = 0; idx < pairs.Length; idx++)
{
if (!files[idx].Revision.Equals(pairs[idx].Value, StringComparison.Ordinal))
{
_logger.LogInformation("Config is out of date (file {Path}: {OldVersion} -> {NewVersion})", files[idx].Uri, pairs[idx].Value, files[idx].Revision);
return true;
}
}
}
return false;
}
catch (Exception ex)
{
_logger.LogInformation(ex, "Exception while checking for config files; assuming out of date");
return true;
}
}
async Task WriteSnapshotAsync(ConfigSnapshot snapshot)
{
ReadOnlyMemory data = SerializeSnapshot(snapshot);
IoHash hash = IoHash.Compute(data.Span);
await _redisService.GetDatabase().StringSetAsync(_snapshotKey, data);
await _redisService.PublishAsync(_updateChannel, RedisValue.EmptyString);
_logger.LogInformation("Published new config snapshot (hash: {Hash}, server: {Server}, size: {Size})", hash.ToString(), ServerApp.Version.ToString(), data.Length);
}
async Task> ReadSnapshotDataAsync()
{
RedisValue value = await _redisService.GetDatabase().StringGetAsync(_snapshotKey);
return value.IsNullOrEmpty ? ReadOnlyMemory.Empty : (ReadOnlyMemory)value;
}
static ReadOnlyMemory SerializeSnapshot(ConfigSnapshot snapshot)
{
using (MemoryStream outputStream = new MemoryStream())
{
using GZipStream zipStream = new GZipStream(outputStream, CompressionMode.Compress, false);
ProtoBuf.Serializer.Serialize(zipStream, snapshot);
zipStream.Flush();
return outputStream.ToArray();
}
}
static ConfigSnapshot DeserializeSnapshot(ReadOnlyMemory data)
{
using (ReadOnlyMemoryStream outputStream = new ReadOnlyMemoryStream(data))
{
using GZipStream zipStream = new GZipStream(outputStream, CompressionMode.Decompress, false);
return ProtoBuf.Serializer.Deserialize(zipStream);
}
}
async Task UpdateConfigObjectAsync(ReadOnlyMemory data)
{
ConfigState state = await _stateTask;
// Hash the data and only update if it changes; this prevents any double-updates due to time between initialization and the hosted service starting.
IoHash hash = IoHash.Compute(data.Span);
if (hash != state.Hash && state.Hash != IoHash.Zero) // Don't replace any explicit config override
{
GlobalConfig globalConfig = await CreateGlobalConfigAsync(data);
_logger.LogInformation("Updating config state from Redis (hash: {OldHash} -> {NewHash}, new size: {Size})", state.Hash, hash, data.Length);
Set(hash, globalConfig);
}
}
async Task CreateGlobalConfigAsync(ReadOnlyMemory data)
{
// Decompress the snapshot object
ConfigSnapshot snapshot = DeserializeSnapshot(data);
// Build the new config and store the project and stream configs inside it
GlobalConfig globalConfig = JsonSerializer.Deserialize(snapshot.Data, _jsonOptions)!;
globalConfig.Revision = IoHash.Compute(data.Span).ToString();
// Run the postload callbacks on all the config objects
await globalConfig.PostLoadAsync(_serverSettings, _pluginCollection.LoadedPlugins, _defaultAclModifiers);
return globalConfig;
}
}
}