From 55ebe3eb484401987c33529b53c74c8bb06288f4 Mon Sep 17 00:00:00 2001 From: John Lambert Date: Tue, 6 Feb 2024 21:50:58 -0500 Subject: [PATCH] updates from reviewer feedback --- .../IMachineBuilderExtensions.cs | 4 +- .../Services/CleanupOldModelsService.cs | 79 -------------- .../Services/IFileStorage.cs | 2 +- .../Services/ISharedFileService.cs | 4 +- .../Services/InMemoryStorage.cs | 6 +- .../Services/LocalStorage.cs | 6 +- .../Services/ModelCleanupService.cs | 100 ++++++++++++++++++ .../Services/NmtClearMLBuildJobFactory.cs | 4 +- .../Services/NmtEngineService.cs | 16 ++- .../Services/NmtTrainBuildJob.cs | 2 + .../Services/S3FileStorage.cs | 8 +- .../ServalTranslationEngineServiceV1.cs | 4 - .../Services/SharedFileService.cs | 9 +- .../Services/SmtTransferEngineService.cs | 6 +- src/SIL.Machine.AspNetCore/Usings.cs | 1 - ...obTests.cs => ModelCleanupServiceTests.cs} | 23 ++-- tests/SIL.Machine.AspNetCore.Tests/Usings.cs | 1 + 17 files changed, 150 insertions(+), 125 deletions(-) delete mode 100644 src/SIL.Machine.AspNetCore/Services/CleanupOldModelsService.cs create mode 100644 src/SIL.Machine.AspNetCore/Services/ModelCleanupService.cs rename tests/SIL.Machine.AspNetCore.Tests/Services/{CleanupJobTests.cs => ModelCleanupServiceTests.cs} (79%) diff --git a/src/SIL.Machine.AspNetCore/Configuration/IMachineBuilderExtensions.cs b/src/SIL.Machine.AspNetCore/Configuration/IMachineBuilderExtensions.cs index 6089d64a9..bad6a2113 100644 --- a/src/SIL.Machine.AspNetCore/Configuration/IMachineBuilderExtensions.cs +++ b/src/SIL.Machine.AspNetCore/Configuration/IMachineBuilderExtensions.cs @@ -407,8 +407,8 @@ public static IMachineBuilder AddBuildJobService(this IMachineBuilder builder) public static IMachineBuilder AddModelCleanupService(this IMachineBuilder builder) { - builder.Services.AddSingleton(); - builder.Services.AddHostedService(p => p.GetRequiredService()); + builder.Services.AddSingleton(); + builder.Services.AddHostedService(p => p.GetRequiredService()); return builder; } diff --git a/src/SIL.Machine.AspNetCore/Services/CleanupOldModelsService.cs b/src/SIL.Machine.AspNetCore/Services/CleanupOldModelsService.cs deleted file mode 100644 index 2e302b880..000000000 --- a/src/SIL.Machine.AspNetCore/Services/CleanupOldModelsService.cs +++ /dev/null @@ -1,79 +0,0 @@ -namespace SIL.Machine.AspNetCore.Services; - -public class CleanupOldModelsService( - IServiceProvider services, - ISharedFileService sharedFileService, - IRepository engines, - ILogger logger -) : RecurrentTask("Cleanup Old Models Service", services, RefreshPeriod, logger) -{ - public ISharedFileService SharedFileService { get; } = sharedFileService; - private ILogger _logger = logger; - private IRepository _engines = engines; - private List _filesPreviouslyMarkedForDeletion = []; - private readonly List _filesNewlyMarkedForDeletion = []; - private static readonly TimeSpan RefreshPeriod = TimeSpan.FromDays(1); - - protected override async Task DoWorkAsync(IServiceScope scope, CancellationToken cancellationToken) - { - await CheckModelsAsync(); - } - - public async Task CheckModelsAsync() - { - _logger.LogInformation("Running model cleanup job"); - var paths = await SharedFileService.ListFilesAsync(ISharedFileService.ModelDirectory); - foreach (string path in paths) - { - var filename = Path.GetFileName(path); - var filenameWithoutExtensions = filename.Split(".")[0]; - var extension = filename[^(filename.Length - filenameWithoutExtensions.Length)..]; - if (extension != ".tar.gz") - { - await DeleteFileAsync(path, $"filename has to have .tar.gz extension, instead has {extension}"); - continue; - } - string[] parts = filenameWithoutExtensions.Split("_"); - if (parts.Length != 2) - { - await DeleteFileAsync(path, $"filename has to have one underscore, instead has {parts.Length - 1}"); - continue; - } - string engineId = parts[0]; - TranslationEngine? engine = await _engines.GetAsync(e => e.EngineId == engineId); - if (engine is null) - { - await DeleteFileAsync(path, $"engine {engineId} does not exist in the database."); - continue; - } - if (!int.TryParse(parts[1], out int parsedBuildRevision)) - { - await DeleteFileAsync(path, $"cannot parse build revision from {parts[1]} for engine {engineId}"); - continue; - } - if (engine.BuildRevision > parsedBuildRevision) - await DeleteFileAsync( - path, - $"build revision {parsedBuildRevision} is older than the current build revision {engine.BuildRevision}" - ); - } - // roll over the list of files previously marked for deletion - _filesPreviouslyMarkedForDeletion = new List(_filesNewlyMarkedForDeletion); - _filesNewlyMarkedForDeletion.Clear(); - } - - private async Task DeleteFileAsync(string filename, string message) - { - // If a file has been requested to be deleted twice, delete it. Otherwise, mark it for deletion. - if (_filesPreviouslyMarkedForDeletion.Contains(filename)) - { - _logger.LogInformation("Deleting old model file {filename}: {message}", filename, message); - await SharedFileService.DeleteAsync(filename); - } - else - { - _logger.LogInformation("Marking old model file {filename} for deletion: {message}", filename, message); - _filesNewlyMarkedForDeletion.Add(filename); - } - } -} diff --git a/src/SIL.Machine.AspNetCore/Services/IFileStorage.cs b/src/SIL.Machine.AspNetCore/Services/IFileStorage.cs index 5e5c44c01..3417cffae 100644 --- a/src/SIL.Machine.AspNetCore/Services/IFileStorage.cs +++ b/src/SIL.Machine.AspNetCore/Services/IFileStorage.cs @@ -14,7 +14,7 @@ Task> ListFilesAsync( Task OpenWriteAsync(string path, CancellationToken cancellationToken = default); - Task GetPresignedUrlAsync(string path, int minutesToExpire, CancellationToken cancellationToken = default); + Task GetDownloadUrlAsync(string path, DateTime expiresAt, CancellationToken cancellationToken = default); Task DeleteAsync(string path, bool recurse = false, CancellationToken cancellationToken = default); } diff --git a/src/SIL.Machine.AspNetCore/Services/ISharedFileService.cs b/src/SIL.Machine.AspNetCore/Services/ISharedFileService.cs index 144c6883f..f082a79c2 100644 --- a/src/SIL.Machine.AspNetCore/Services/ISharedFileService.cs +++ b/src/SIL.Machine.AspNetCore/Services/ISharedFileService.cs @@ -2,13 +2,11 @@ public interface ISharedFileService { - public const string ModelDirectory = "models/"; - Uri GetBaseUri(); Uri GetResolvedUri(string path); - Task GetPresignedUrlAsync(string path, int minutesToExpire); + Task GetDownloadUrlAsync(string path, DateTime expiresAt); Task> ListFilesAsync( string path, diff --git a/src/SIL.Machine.AspNetCore/Services/InMemoryStorage.cs b/src/SIL.Machine.AspNetCore/Services/InMemoryStorage.cs index b86613672..e92109a39 100644 --- a/src/SIL.Machine.AspNetCore/Services/InMemoryStorage.cs +++ b/src/SIL.Machine.AspNetCore/Services/InMemoryStorage.cs @@ -96,13 +96,13 @@ public Task> ListFilesAsync( ); } - public Task GetPresignedUrlAsync( + public Task GetDownloadUrlAsync( string path, - int minutesToExpire, + DateTime expiresAt, CancellationToken cancellationToken = default ) { - return Task.FromResult(path); + throw new NotSupportedException(); } public Task OpenReadAsync(string path, CancellationToken cancellationToken = default) diff --git a/src/SIL.Machine.AspNetCore/Services/LocalStorage.cs b/src/SIL.Machine.AspNetCore/Services/LocalStorage.cs index b79ef0976..9fc26c097 100644 --- a/src/SIL.Machine.AspNetCore/Services/LocalStorage.cs +++ b/src/SIL.Machine.AspNetCore/Services/LocalStorage.cs @@ -36,13 +36,13 @@ public Task> ListFilesAsync( ); } - public Task GetPresignedUrlAsync( + public Task GetDownloadUrlAsync( string path, - int minutesToExpire, + DateTime expiresAt, CancellationToken cancellationToken = default ) { - return Task.FromResult(path); + throw new NotSupportedException(); } public Task OpenReadAsync(string path, CancellationToken cancellationToken = default) diff --git a/src/SIL.Machine.AspNetCore/Services/ModelCleanupService.cs b/src/SIL.Machine.AspNetCore/Services/ModelCleanupService.cs new file mode 100644 index 000000000..c666624d0 --- /dev/null +++ b/src/SIL.Machine.AspNetCore/Services/ModelCleanupService.cs @@ -0,0 +1,100 @@ +namespace SIL.Machine.AspNetCore.Services; + +public class ModelCleanupService( + IServiceProvider services, + ISharedFileService sharedFileService, + IRepository engines, + ILogger logger +) : RecurrentTask("Model Cleanup Service", services, RefreshPeriod, logger) +{ + private ISharedFileService SharedFileService { get; } = sharedFileService; + private ILogger _logger = logger; + private IRepository _engines = engines; + private List _filesPreviouslyMarkedForDeletion = []; + private readonly List _filesNewlyMarkedForDeletion = []; + private static readonly TimeSpan RefreshPeriod = TimeSpan.FromSeconds(10); + + protected override async Task DoWorkAsync(IServiceScope scope, CancellationToken cancellationToken) + { + await CheckModelsAsync(cancellationToken); + } + + private async Task CheckModelsAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("Running model cleanup job"); + IReadOnlyCollection paths = await SharedFileService.ListFilesAsync( + NmtEngineService.ModelDirectory, + cancellationToken: cancellationToken + ); + // Get all engine ids from the database + Dictionary engineIdsToRevision = _engines + .GetAllAsync(cancellationToken: cancellationToken) + .Result.Select(e => (e.EngineId, e.BuildRevision)) + .ToDictionary(); + + foreach (string path in paths) + { + string filename = Path.GetFileName(path); + string filenameWithoutExtensions = filename.Split(".")[0]; + string extension = filename[^(filename.Length - filenameWithoutExtensions.Length)..]; + if (extension != ".tar.gz") + { + await DeleteFileAsync( + path, + $"filename has to have .tar.gz extension, instead has {extension}", + cancellationToken + ); + continue; + } + string[] parts = filenameWithoutExtensions.Split("_"); + if (parts.Length != 2) + { + await DeleteFileAsync( + path, + $"filename has to have one underscore, instead has {parts.Length - 1}", + cancellationToken + ); + continue; + } + string engineId = parts[0]; + if (!engineIdsToRevision.ContainsKey(engineId)) + { + await DeleteFileAsync(path, $"engine {engineId} does not exist in the database.", cancellationToken); + continue; + } + if (!int.TryParse(parts[1], out int parsedBuildRevision)) + { + await DeleteFileAsync( + path, + $"cannot parse build revision from {parts[1]} for engine {engineId}", + cancellationToken + ); + continue; + } + if (engineIdsToRevision[engineId] > parsedBuildRevision) + await DeleteFileAsync( + path, + $"build revision {parsedBuildRevision} is older than the current build revision {engineIdsToRevision[engineId]}", + cancellationToken + ); + } + // roll over the list of files previously marked for deletion + _filesPreviouslyMarkedForDeletion = new List(_filesNewlyMarkedForDeletion); + _filesNewlyMarkedForDeletion.Clear(); + } + + private async Task DeleteFileAsync(string filename, string message, CancellationToken cancellationToken = default) + { + // If a file has been requested to be deleted twice, delete it. Otherwise, mark it for deletion. + if (_filesPreviouslyMarkedForDeletion.Contains(filename)) + { + _logger.LogInformation("Deleting old model file {filename}: {message}", filename, message); + await SharedFileService.DeleteAsync(filename, cancellationToken); + } + else + { + _logger.LogInformation("Marking old model file {filename} for deletion: {message}", filename, message); + _filesNewlyMarkedForDeletion.Add(filename); + } + } +} diff --git a/src/SIL.Machine.AspNetCore/Services/NmtClearMLBuildJobFactory.cs b/src/SIL.Machine.AspNetCore/Services/NmtClearMLBuildJobFactory.cs index 87fa8528a..dfc8423ea 100644 --- a/src/SIL.Machine.AspNetCore/Services/NmtClearMLBuildJobFactory.cs +++ b/src/SIL.Machine.AspNetCore/Services/NmtClearMLBuildJobFactory.cs @@ -44,7 +44,9 @@ public async Task CreateJobScriptAsync( + $" 'shared_file_uri': '{baseUri}',\n" + $" 'shared_file_folder': '{folder}',\n" + (buildOptions is not null ? $" 'build_options': '''{buildOptions}''',\n" : "") - + (engine.IsModelPersisted ? $" 'save_model': '{engineId}_{engine.BuildRevision + 1}',\n" : "") + // buildRevision + 1 because the build revision is incremented after the build job + // is finished successfully but the file should be saved with the new revision number + + (engine.IsModelPersisted ? $" 'save_model': '{engineId}_{engine.BuildRevision + 1}',\n" : $"") + $" 'clearml': True\n" + "}\n" + "run(args)\n"; diff --git a/src/SIL.Machine.AspNetCore/Services/NmtEngineService.cs b/src/SIL.Machine.AspNetCore/Services/NmtEngineService.cs index 962879a32..90e16b0ff 100644 --- a/src/SIL.Machine.AspNetCore/Services/NmtEngineService.cs +++ b/src/SIL.Machine.AspNetCore/Services/NmtEngineService.cs @@ -27,6 +27,8 @@ ISharedFileService sharedFileService private readonly ILanguageTagService _languageTagService = languageTagService; private readonly ISharedFileService _sharedFileService = sharedFileService; + public const string ModelDirectory = "models/"; + public TranslationEngineType Type => TranslationEngineType.Nmt; private const int MinutesToExpire = 60; @@ -121,7 +123,7 @@ public async Task GetModelDownloadUrlAsync( { TranslationEngine engine = await GetEngineAsync(engineId, cancellationToken); if (!engine.IsModelPersisted) - throw new InvalidOperationException( + throw new NotSupportedException( "The model cannot be downloaded. " + "To enable downloading the model, recreate the engine with IsModelPersisted property to true." ); @@ -129,23 +131,19 @@ public async Task GetModelDownloadUrlAsync( throw new InvalidOperationException("The engine has not been built yet."); string filename = $"{engineId}_{engine.BuildRevision}.tar.gz"; bool fileExists = await _sharedFileService.ExistsAsync( - ISharedFileService.ModelDirectory + filename, + NmtEngineService.ModelDirectory + filename, cancellationToken ); if (!fileExists) throw new FileNotFoundException( $"The model should exist to be downloaded but is not there for BuildRevision {engine.BuildRevision}." ); + var expiresAt = DateTime.UtcNow.AddMinutes(MinutesToExpire); var modelInfo = new ModelDownloadUrl { - Url = ( - await _sharedFileService.GetPresignedUrlAsync( - ISharedFileService.ModelDirectory + filename, - MinutesToExpire - ) - ).ToString(), + Url = await _sharedFileService.GetDownloadUrlAsync(NmtEngineService.ModelDirectory + filename, expiresAt), ModelRevision = engine.BuildRevision, - ExipiresAt = DateTime.UtcNow.AddMinutes(MinutesToExpire) + ExipiresAt = expiresAt }; return modelInfo; } diff --git a/src/SIL.Machine.AspNetCore/Services/NmtTrainBuildJob.cs b/src/SIL.Machine.AspNetCore/Services/NmtTrainBuildJob.cs index d3919a623..8ed1020d0 100644 --- a/src/SIL.Machine.AspNetCore/Services/NmtTrainBuildJob.cs +++ b/src/SIL.Machine.AspNetCore/Services/NmtTrainBuildJob.cs @@ -68,6 +68,8 @@ await PipInstallModuleAsync( + $" 'trg_lang': '{ConvertLanguageTag(engine.TargetLanguage)}',\n" + $" 'shared_file_uri': '{_sharedFileService.GetBaseUri()}',\n" + (buildOptions is not null ? $" 'build_options': '''{buildOptions}''',\n" : "") + // buildRevision + 1 because the build revision is incremented after the build job + // is finished successfully but the file should be saved with the new revision number + ( engine.IsModelPersisted ? $" 'save_model': '{engine.Id}_{engine.BuildRevision + 1}',\n" diff --git a/src/SIL.Machine.AspNetCore/Services/S3FileStorage.cs b/src/SIL.Machine.AspNetCore/Services/S3FileStorage.cs index c8420bb19..176831406 100644 --- a/src/SIL.Machine.AspNetCore/Services/S3FileStorage.cs +++ b/src/SIL.Machine.AspNetCore/Services/S3FileStorage.cs @@ -1,3 +1,5 @@ +using static SIL.Machine.AspNetCore.Utils.SharedFileUtils; + namespace SIL.Machine.AspNetCore.Services; public class S3FileStorage : DisposableBase, IFileStorage @@ -63,9 +65,9 @@ public async Task> ListFilesAsync( return response.S3Objects.Select(s3Obj => s3Obj.Key[_basePath.Length..]).ToList(); } - public Task GetPresignedUrlAsync( + public Task GetDownloadUrlAsync( string path, - int minutesToExpire, + DateTime expiresAt, CancellationToken cancellationToken = default ) { @@ -75,7 +77,7 @@ public Task GetPresignedUrlAsync( { BucketName = _bucketName, Key = _basePath + Normalize(path), - Expires = DateTime.UtcNow.AddMinutes(minutesToExpire), + Expires = expiresAt, ResponseHeaderOverrides = new ResponseHeaderOverrides { ContentDisposition = new ContentDisposition() { FileName = Path.GetFileName(path) }.ToString() diff --git a/src/SIL.Machine.AspNetCore/Services/ServalTranslationEngineServiceV1.cs b/src/SIL.Machine.AspNetCore/Services/ServalTranslationEngineServiceV1.cs index 2e31071fe..6422df8fb 100644 --- a/src/SIL.Machine.AspNetCore/Services/ServalTranslationEngineServiceV1.cs +++ b/src/SIL.Machine.AspNetCore/Services/ServalTranslationEngineServiceV1.cs @@ -143,10 +143,6 @@ ServerCallContext context { throw new RpcException(new Status(StatusCode.Aborted, e.Message)); } - catch (FileNotFoundException e) - { - throw new RpcException(new Status(StatusCode.Aborted, e.Message)); - } } public override async Task GetQueueSize( diff --git a/src/SIL.Machine.AspNetCore/Services/SharedFileService.cs b/src/SIL.Machine.AspNetCore/Services/SharedFileService.cs index cbdb60836..b4244211e 100644 --- a/src/SIL.Machine.AspNetCore/Services/SharedFileService.cs +++ b/src/SIL.Machine.AspNetCore/Services/SharedFileService.cs @@ -55,14 +55,9 @@ public Uri GetResolvedUri(string path) return new Uri(_baseUri, path); } - public async Task GetPresignedUrlAsync(string path, int minutesToExpire) + public async Task GetDownloadUrlAsync(string path, DateTime expiresAt) { - string presignedUrl = path; - if (_baseUri is not null) - if (_baseUri.Scheme == "s3") - presignedUrl = await _fileStorage.GetPresignedUrlAsync(path, minutesToExpire); - var url = GetResolvedUri(presignedUrl); - return url; + return await _fileStorage.GetDownloadUrlAsync(path, expiresAt); } public Task> ListFilesAsync( diff --git a/src/SIL.Machine.AspNetCore/Services/SmtTransferEngineService.cs b/src/SIL.Machine.AspNetCore/Services/SmtTransferEngineService.cs index 7722970fb..c390e882d 100644 --- a/src/SIL.Machine.AspNetCore/Services/SmtTransferEngineService.cs +++ b/src/SIL.Machine.AspNetCore/Services/SmtTransferEngineService.cs @@ -32,7 +32,7 @@ public async Task CreateAsync( string? engineName, string sourceLanguage, string targetLanguage, - bool isModelPersisted = false, + bool isModelPersisted = true, CancellationToken cancellationToken = default ) { @@ -43,7 +43,7 @@ await _engines.InsertAsync( EngineId = engineId, SourceLanguage = sourceLanguage, TargetLanguage = targetLanguage, - IsModelPersisted = isModelPersisted + IsModelPersisted = true // SMT transfer engines are always persisted }, cancellationToken ); @@ -220,7 +220,7 @@ public Task GetModelDownloadUrlAsync( CancellationToken cancellationToken = default ) { - throw new NotImplementedException(); + throw new NotSupportedException(); } private async Task GetEngineAsync(string engineId, CancellationToken cancellationToken) diff --git a/src/SIL.Machine.AspNetCore/Usings.cs b/src/SIL.Machine.AspNetCore/Usings.cs index 9dac039f4..6757c8c25 100644 --- a/src/SIL.Machine.AspNetCore/Usings.cs +++ b/src/SIL.Machine.AspNetCore/Usings.cs @@ -47,7 +47,6 @@ global using SIL.Machine.AspNetCore.Models; global using SIL.Machine.AspNetCore.Services; global using SIL.Machine.AspNetCore.Utils; -global using static SIL.Machine.AspNetCore.Utils.SharedFileUtils; global using SIL.Machine.Corpora; global using SIL.Machine.Morphology.HermitCrab; global using SIL.Machine.Tokenization; diff --git a/tests/SIL.Machine.AspNetCore.Tests/Services/CleanupJobTests.cs b/tests/SIL.Machine.AspNetCore.Tests/Services/ModelCleanupServiceTests.cs similarity index 79% rename from tests/SIL.Machine.AspNetCore.Tests/Services/CleanupJobTests.cs rename to tests/SIL.Machine.AspNetCore.Tests/Services/ModelCleanupServiceTests.cs index 0db8163a1..6bee1df38 100644 --- a/tests/SIL.Machine.AspNetCore.Tests/Services/CleanupJobTests.cs +++ b/tests/SIL.Machine.AspNetCore.Tests/Services/ModelCleanupServiceTests.cs @@ -1,11 +1,10 @@ namespace SIL.Machine.AspNetCore.Services; [TestFixture] -public class CleanupJobTests +public class ModelCleanupServiceTests { private readonly ISharedFileService _sharedFileService = new SharedFileService(Substitute.For()); private readonly MemoryRepository _engines = new MemoryRepository(); - private readonly InMemoryStorage _memoryStorage = new(); private static readonly List validFiles = ["models/engineId1_1.tar.gz", "models/engineId2_2.tar.gz"]; private static readonly List invalidFiles = [ @@ -56,23 +55,35 @@ async Task WriteFileStub(string path, string content) } } + public class TestModelCleanupService( + IServiceProvider serviceProvider, + ISharedFileService sharedFileService, + IRepository engines, + ILogger logger + ) : ModelCleanupService(serviceProvider, sharedFileService, engines, logger) + { + public async Task DoWorkAsync() => + await base.DoWorkAsync(Substitute.For(), CancellationToken.None); + } + [Test] public async Task DoWorkAsync_ValidFiles() { await SetUpAsync(); - var cleanupJob = new CleanupOldModelsService( + + var cleanupJob = new TestModelCleanupService( Substitute.For(), _sharedFileService, _engines, - Substitute.For>() + Substitute.For>() ); - await cleanupJob.CheckModelsAsync(); + await cleanupJob.DoWorkAsync(); // both valid and invalid files still exist after running once Assert.That( _sharedFileService.ListFilesAsync("models").Result.ToHashSet(), Is.EquivalentTo(validFiles.Concat(invalidFiles).ToHashSet()) ); - await cleanupJob.CheckModelsAsync(); + await cleanupJob.DoWorkAsync(); // only valid files exist after running twice Assert.That( _sharedFileService.ListFilesAsync("models").Result.ToHashSet(), diff --git a/tests/SIL.Machine.AspNetCore.Tests/Usings.cs b/tests/SIL.Machine.AspNetCore.Tests/Usings.cs index 222a7a747..4aafc901d 100644 --- a/tests/SIL.Machine.AspNetCore.Tests/Usings.cs +++ b/tests/SIL.Machine.AspNetCore.Tests/Usings.cs @@ -3,6 +3,7 @@ global using System.Text.Json.Nodes; global using Hangfire; global using Hangfire.Storage; +global using Microsoft.Extensions.DependencyInjection; global using Microsoft.Extensions.Hosting; global using Microsoft.Extensions.Hosting.Internal; global using Microsoft.Extensions.Logging;