Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/Persistence/ByDataSourceReferenceHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,13 @@ public ByDataSourceReferenceHandler(IDataSource<GameConfiguration> dataSource)
this._dataSource = dataSource;
}

/// <summary>
/// Gets the data source which is used to resolve the references.
/// It's exposed, so that it can be reloaded when the underlying data changed,
/// e.g. after the database has been (re-)initialized.
/// </summary>
public IDataSource<GameConfiguration> DataSource => this._dataSource;

/// <inheritdoc />
public override ReferenceResolver CreateResolver()
{
Expand Down
27 changes: 27 additions & 0 deletions src/Persistence/DataInitializationState.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// <copyright file="DataInitializationState.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>

namespace MUnique.OpenMU.Persistence;

/// <summary>
/// The state of the data initialization of a database.
/// </summary>
public enum DataInitializationState
{
/// <summary>
/// It could not be determined if the data is initialized, e.g. because the
/// check failed or timed out. It's not safe to assume that the database is empty.
/// </summary>
Unknown,

/// <summary>
/// The database doesn't contain a game configuration yet.
/// </summary>
NotInitialized,

/// <summary>
/// The database contains a game configuration.
/// </summary>
Initialized,
}
23 changes: 20 additions & 3 deletions src/Persistence/SetupService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -92,17 +92,28 @@ public bool IsUpdateRequired
/// Gets a value indicating whether the data is initialized.
/// </summary>
public async ValueTask<bool> IsDataInitializedAsync()
{
return await this.GetDataInitializationStateAsync().ConfigureAwait(false) == DataInitializationState.Initialized;
}

/// <summary>
/// Gets the state of the data initialization.
/// In contrast to <see cref="IsDataInitializedAsync"/>, it tells a failed check
/// apart from an actually empty database.
/// </summary>
/// <returns>The state of the data initialization.</returns>
public async ValueTask<DataInitializationState> GetDataInitializationStateAsync()
{
try
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
using var context = this._contextProvider.CreateNewConfigurationContext();
var id = await context.GetDefaultGameConfigurationIdAsync(cts.Token).ConfigureAwait(false);
return id is not null;
return id is not null ? DataInitializationState.Initialized : DataInitializationState.NotInitialized;
}
catch
{
return false;
return DataInitializationState.Unknown;
}
}

Expand Down Expand Up @@ -163,7 +174,13 @@ public async Task CreateDatabaseAsync(Func<Task> dataInitialization)
await dataInitialization().ConfigureAwait(false);
if (this.DatabaseInitialized is { } eventHandler)
{
await eventHandler.Invoke().ConfigureAwait(false);
// We have to invoke the subscribers one after another, because a multicast
// delegate would just return the ValueTask of the last subscriber. The
// subscribers rely on their registration order, so they must not run in parallel.
foreach (var subscriber in eventHandler.GetInvocationList().OfType<AsyncEventHandler>())
{
await subscriber.Invoke().ConfigureAwait(false);
}
}
}
}
56 changes: 49 additions & 7 deletions src/PlugIns/PlugInManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace MUnique.OpenMU.PlugIns;

using System.Collections.Concurrent;
using System.ComponentModel;
using System.ComponentModel.Design;
using System.Reflection;
using System.Runtime.InteropServices;
Expand All @@ -25,6 +26,7 @@ public class PlugInManager
private readonly IDictionary<Guid, Type> _knownPlugIns = new ConcurrentDictionary<Guid, Type>();
private readonly ConcurrentDictionary<Type, ISet<Type>> _knownPlugInsPerInterfaceType = new();
private readonly ConcurrentDictionary<Guid, Type> _activePlugIns = new();
private readonly List<(PlugInConfiguration Configuration, PropertyChangedEventHandler Handler)> _configurationSubscriptions = new();
private object? _lastCreatedPlugIn;

/// <summary>
Expand All @@ -48,11 +50,7 @@ public PlugInManager(ICollection<PlugInConfiguration>? configurations, ILoggerFa
if (configurations is not null)
{
this.DiscoverAndRegisterPlugIns();
var loadedAssemblies = new HashSet<string>();
foreach (var configuration in configurations)
{
this.ReadConfiguration(configuration, loadedAssemblies);
}
this.ReadConfigurations(configurations);
}
}

Expand Down Expand Up @@ -84,6 +82,32 @@ public PlugInManager(ICollection<PlugInConfiguration>? configurations, ILoggerFa
/// </summary>
public ReferenceHandler? CustomConfigReferenceHandler { get; }

/// <summary>
/// Reads the given plugin configurations and applies them to the known plugins.
/// It can be called again later, e.g. when the configurations became available
/// after the database has been initialized. In this case, the previously read
/// configurations are not observed anymore.
/// </summary>
/// <param name="configurations">The plugin configurations.</param>
public void ReadConfigurations(IEnumerable<PlugInConfiguration> configurations)
{
this.UnsubscribeFromConfigurations();

var loadedAssemblies = new HashSet<string>();
foreach (var configuration in configurations)
{
try
{
this.ReadConfiguration(configuration, loadedAssemblies);
}
catch (Exception ex)
{
// A failing configuration must not stop the remaining ones from being applied.
this._logger.LogError(ex, "Error when reading the configuration of plugin {TypeId}.", configuration.TypeId);
}
}
}

/// <summary>
/// Discovers and registers all plugins of all loaded assemblies.
/// </summary>
Expand Down Expand Up @@ -450,7 +474,13 @@ private void ReadConfiguration(PlugInConfiguration configuration, HashSet<string

if (this._knownPlugIns.TryGetValue(configuration.TypeId, out var plugInType))
{
if (!configuration.IsActive)
if (configuration.IsActive)
{
// Plugins are active by default when they get registered, but that's not
// necessarily the case anymore when the configurations are read again.
this.ActivatePlugIn(plugInType);
}
else
{
this.DeactivatePlugIn(plugInType);
}
Expand All @@ -459,14 +489,26 @@ private void ReadConfiguration(PlugInConfiguration configuration, HashSet<string

// When the IsActive property changed, we activate/deactivate accordingly.
// Currently, property changes are only fired for IsActive, so we don't need to check it.
configuration.PropertyChanged += (sender, args) => this.OnConfigurationChanged(configuration, plugInType, args.PropertyName);
PropertyChangedEventHandler handler = (sender, args) => this.OnConfigurationChanged(configuration, plugInType, args.PropertyName);
configuration.PropertyChanged += handler;
this._configurationSubscriptions.Add((configuration, handler));
}
else
{
this._logger.LogWarning("Unknown plugin type for id {TypeId}", configuration.TypeId);
}
}

private void UnsubscribeFromConfigurations()
{
foreach (var (configuration, handler) in this._configurationSubscriptions)
{
configuration.PropertyChanged -= handler;
}

this._configurationSubscriptions.Clear();
}

private void OnConfigurationChanged(PlugInConfiguration configuration, Type plugInType, string? propertyName)
{
if (propertyName == nameof(PlugInConfiguration.IsActive))
Expand Down
84 changes: 80 additions & 4 deletions src/Startup/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -322,8 +322,20 @@ private async Task<IHost> CreateHostAsync(string[] args)
var dataSource = new GameConfigurationDataSource(
provider.GetService<ILogger<GameConfigurationDataSource>>()!,
persistenceContextProvider!);
var configId = persistenceContextProvider!.CreateNewConfigurationContext().GetDefaultGameConfigurationIdAsync(default).AsTask().WaitAndUnwrapException();
dataSource.GetOwnerAsync(configId!.Value).AsTask().WaitAndUnwrapException();
using var configurationContext = persistenceContextProvider!.CreateNewConfigurationContext();
var configId = configurationContext.GetDefaultGameConfigurationIdAsync(default).AsTask().WaitAndUnwrapException();
if (configId is { } gameConfigurationId)
{
dataSource.GetOwnerAsync(gameConfigurationId).AsTask().WaitAndUnwrapException();
}
else
{
// The database doesn't contain a game configuration yet. It's created later,
// e.g. through the admin panel. The data source is then loaded again,
// see OnDatabaseInitializedAsync.
this._logger.Debug("No game configuration found in the database, so the data source is not loaded yet.");
}

var referenceHandler = new ByDataSourceReferenceHandler(dataSource);
return referenceHandler;
})
Expand All @@ -347,6 +359,13 @@ private async Task<IHost> CreateHostAsync(string[] args)
host.ConfigureAdminPanel();
}

// When the server is started with an uninitialized database, the plugin configurations
// are not available yet. They're created during the data initialization, so we have to
// load them afterwards. The subscribers are invoked one after another in the order of
// their registration, so we subscribe before the host is started (and with it, the
// server containers) to get the plugins ready before the servers are restarted.
host.Services.GetRequiredService<SetupService>().DatabaseInitialized += () => this.OnDatabaseInitializedAsync(host.Services);
Comment thread
sven-n marked this conversation as resolved.

this._logger.Information("Starting host...");
var stopwatch = new Stopwatch();
stopwatch.Start();
Expand Down Expand Up @@ -436,13 +455,70 @@ private ICollection<PlugInConfiguration> PlugInConfigurationsFactory(IServicePro
return configs;
}

private async ValueTask OnDatabaseInitializedAsync(IServiceProvider services)
{
try
{
if (services.GetService<PlugInManager>() is not { } plugInManager
|| services.GetService<IPersistenceContextProvider>() is not { } persistenceContextProvider)
{
return;
}

var configurations = await this.LoadPlugInConfigurationsAsync(plugInManager, persistenceContextProvider).ConfigureAwait(false);
plugInManager.ReadConfigurations(configurations);
Comment thread
sven-n marked this conversation as resolved.

if (services.GetService<ICollection<PlugInConfiguration>>() is { } registeredConfigurations)
{
// The registered collection was created before the data initialization
// and would keep the outdated (or empty) configurations otherwise.
registeredConfigurations.Clear();
configurations.ForEach(registeredConfigurations.Add);
}

this._logger.Information("Applied {count} plugin configurations after the database initialization.", configurations.Count);
}
catch (Exception ex)
{
this._logger.Error(ex, "Error when applying the plugin configurations after the database initialization.");
}
}

private async ValueTask<List<PlugInConfiguration>> LoadPlugInConfigurationsAsync(PlugInManager plugInManager, IPersistenceContextProvider persistenceContextProvider)
{
if (plugInManager.CustomConfigReferenceHandler is ByDataSourceReferenceHandler referenceHandler)
{
// The data source of the reference handler was loaded (or not) before the data
// initialization. Without reloading it, the references within the custom plugin
// configurations would be resolved on the previous - now deleted - game configuration.
var dataSource = referenceHandler.DataSource;
await dataSource.ForceDiscardChangesAsync().ConfigureAwait(false);
var gameConfiguration = await dataSource.GetOwnerAsync().ConfigureAwait(false);

// The configurations of the data source are used, so that they stay alive
// as long as the reference handler does.
return gameConfiguration.PlugInConfigurations.ToList();
}

using var context = persistenceContextProvider.CreateNewTypedContext(typeof(PlugInConfiguration), false);
return (await context.GetAsync<PlugInConfiguration>().ConfigureAwait(false)).ToList();
}

private IEnumerable<PlugInConfiguration> CreateMissingPlugInConfigurations(IEnumerable<Type> plugInTypes, IPersistenceContextProvider persistenceContextProvider, ReferenceHandler referenceHandler)
{
GameConfiguration gameConfiguration;
GameConfiguration? gameConfiguration;

using (var context = persistenceContextProvider.CreateNewContext())
{
gameConfiguration = context.GetAsync<GameConfiguration>().AsTask().WaitAndUnwrapException().First();
gameConfiguration = context.GetAsync<GameConfiguration>().AsTask().WaitAndUnwrapException().FirstOrDefault();
}

if (gameConfiguration is null)
{
// The database is not initialized yet - the plugin configurations are created
// together with the game configuration.
this._logger.Warning("No game configuration found in the database, so the missing plugin configurations can't be created yet.");
yield break;
}

using var saveContext = persistenceContextProvider.CreateNewContext(gameConfiguration);
Expand Down
20 changes: 16 additions & 4 deletions src/Web/AdminPanel/Pages/Setup.razor
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
@page "/setup"
@attribute [Authorize(Policy = AdminPolicies.Administrator)]
@using MUnique.OpenMU.Persistence
@using MUnique.OpenMU.Web.AdminPanel.Properties

<PageTitle>OpenMU: @Resources.Setup</PageTitle>
<Breadcrumb IsFirstFromRoot="true" Caption="@Resources.Setup"/>

@if (this.ShowInstall)
{
<Install InstallationFinished="() => this.ShowInstall = false" />
<Install InstallationFinished="this.OnInstallationFinishedAsync" />
}
else if (!this.SetupService.CanConnectToDatabase)
{
Expand All @@ -26,16 +27,27 @@ else if (this.SetupService.IsUpdateRequired)
<p>@Resources.DatabaseStatus: <span class="badge bg-warning text-dark">@Resources.UpdateRequired</span></p>
<button class="btn btn-primary" @onclick="this.OnUpdateClickAsync">@Resources.Update</button>
}
else if (this._dataState == DataInitializationState.NotInitialized)
{
@* The database exists, but doesn't contain any data yet. For the user, it's the same
as if the database wasn't created yet - it just needs to be installed.
We only offer to create the data without a confirmation when we know for sure
that there is no data which could get lost. *@
<p>@Resources.DatabaseStatus: <span class="badge bg-success">@Resources.UpToDate</span></p>
<p>@Resources.InitializedGameVersion: <span class="badge bg-warning text-dark">@Resources.NoInitializedDataFound</span></p>

<button class="btn btn-primary" @onclick="this.OnInstallClick">@Resources.Create</button>
}
else
{
<p>@Resources.DatabaseStatus: <span class="badge bg-success">@Resources.UpToDate</span></p>
@if (!this._isDataInitialized)
@if (this._dataState == DataInitializationState.Initialized)
{
<p>@Resources.InitializedGameVersion: <span class="badge bg-warning text-dark">@Resources.NoInitializedDataFound</span></p>
<p>@Resources.InitializedGameVersion: <span class="badge bg-success">@this._gameClientVersion</span></p>
}
else
{
<p>@Resources.InitializedGameVersion: <span class="badge bg-success">@this._gameClientVersion</span></p>
<p>@Resources.InitializedGameVersion: <span class="badge bg-warning text-dark">@Resources.NoInitializedDataFound</span></p>
}

<button class="btn btn-warning" @onclick="this.OnReInstallClickAsync">@Resources.ReInstall</button>
Expand Down
Loading
Loading