From bf7bd0ad6b03b1e4b2160841f8d87660cdb04c90 Mon Sep 17 00:00:00 2001 From: Roman Pytkov Date: Sun, 2 Nov 2025 20:05:50 +0300 Subject: [PATCH] Improves package download and caching Adds package caching to reduce download frequency. Introduces a `PackageDownloadService` with memory caching to store downloaded packages, identified by mission ID, for reuse. Uses concurrent locks to prevent race conditions during download. Modifies the worker client service to optionally delete the package after sending, allowing cached packages to be retained. --- .../Controllers/TesterController.cs | 8 +- src/LiquidCode.Tester.Gateway/Program.cs | 1 + .../Services/IPackageDownloadService.cs | 9 +- .../Services/IWorkerClientService.cs | 5 +- .../Services/PackageDownloadService.cs | 112 ++++++++++++++++-- .../Services/WorkerClientService.cs | 21 ++-- 6 files changed, 130 insertions(+), 26 deletions(-) diff --git a/src/LiquidCode.Tester.Gateway/Controllers/TesterController.cs b/src/LiquidCode.Tester.Gateway/Controllers/TesterController.cs index 9b22213..ac52d66 100644 --- a/src/LiquidCode.Tester.Gateway/Controllers/TesterController.cs +++ b/src/LiquidCode.Tester.Gateway/Controllers/TesterController.cs @@ -30,11 +30,11 @@ public class TesterController : ControllerBase try { - // Download the package - var packagePath = await _packageDownloadService.DownloadPackageAsync(request.PackageUrl); + // Download the package or use cached version if available + var packagePath = await _packageDownloadService.GetOrDownloadPackageAsync(request.MissionId, request.PackageUrl); // Send to appropriate worker based on language - await _workerClientService.SendToWorkerAsync(request, packagePath); + await _workerClientService.SendToWorkerAsync(request, packagePath, deletePackageAfterSend: false); return Accepted(new { message = "Submit accepted for testing", submitId = request.Id }); } @@ -86,7 +86,7 @@ public class TesterController : ControllerBase ); // Send to appropriate worker based on language - await _workerClientService.SendToWorkerAsync(submitModel, packagePath); + await _workerClientService.SendToWorkerAsync(submitModel, packagePath, deletePackageAfterSend: true); return Accepted(new { message = "Submit accepted for testing", submitId = request.Id }); } diff --git a/src/LiquidCode.Tester.Gateway/Program.cs b/src/LiquidCode.Tester.Gateway/Program.cs index 4faf1c9..21263ab 100644 --- a/src/LiquidCode.Tester.Gateway/Program.cs +++ b/src/LiquidCode.Tester.Gateway/Program.cs @@ -10,6 +10,7 @@ builder.Services.AddOpenApi(); // Add HttpClient builder.Services.AddHttpClient(); +builder.Services.AddMemoryCache(); // Register application services builder.Services.AddSingleton(); diff --git a/src/LiquidCode.Tester.Gateway/Services/IPackageDownloadService.cs b/src/LiquidCode.Tester.Gateway/Services/IPackageDownloadService.cs index 1c23152..1b44d57 100644 --- a/src/LiquidCode.Tester.Gateway/Services/IPackageDownloadService.cs +++ b/src/LiquidCode.Tester.Gateway/Services/IPackageDownloadService.cs @@ -3,9 +3,10 @@ namespace LiquidCode.Tester.Gateway.Services; public interface IPackageDownloadService { /// - /// Downloads a package from the specified URL + /// Retrieves a cached package for the mission or downloads it if missing. /// - /// URL to download the package from - /// Path to the downloaded package file - Task DownloadPackageAsync(string packageUrl); + /// Unique mission identifier used as cache key. + /// URL to download the package from when cache is cold. + /// Path to the cached or downloaded package file. + Task GetOrDownloadPackageAsync(long missionId, string packageUrl); } diff --git a/src/LiquidCode.Tester.Gateway/Services/IWorkerClientService.cs b/src/LiquidCode.Tester.Gateway/Services/IWorkerClientService.cs index 2d49696..44aee88 100644 --- a/src/LiquidCode.Tester.Gateway/Services/IWorkerClientService.cs +++ b/src/LiquidCode.Tester.Gateway/Services/IWorkerClientService.cs @@ -8,6 +8,7 @@ public interface IWorkerClientService /// Sends a submit to the appropriate worker based on the language /// /// Submit data - /// Local path to the downloaded package - Task SendToWorkerAsync(SubmitForTesterModel submit, string packagePath); + /// Local path to the package that will be streamed to the worker + /// Indicates whether the package file should be removed after upload + Task SendToWorkerAsync(SubmitForTesterModel submit, string packagePath, bool deletePackageAfterSend); } diff --git a/src/LiquidCode.Tester.Gateway/Services/PackageDownloadService.cs b/src/LiquidCode.Tester.Gateway/Services/PackageDownloadService.cs index 5abab22..b636b84 100644 --- a/src/LiquidCode.Tester.Gateway/Services/PackageDownloadService.cs +++ b/src/LiquidCode.Tester.Gateway/Services/PackageDownloadService.cs @@ -1,3 +1,6 @@ +using System.Collections.Concurrent; +using Microsoft.Extensions.Caching.Memory; + namespace LiquidCode.Tester.Gateway.Services; public class PackageDownloadService : IPackageDownloadService @@ -5,14 +8,20 @@ public class PackageDownloadService : IPackageDownloadService private readonly IHttpClientFactory _httpClientFactory; private readonly ILogger _logger; private readonly string _downloadDirectory; + private readonly IMemoryCache _memoryCache; + private readonly ConcurrentDictionary _locks = new(); + + private sealed record PackageCacheEntry(string FilePath); public PackageDownloadService( IHttpClientFactory httpClientFactory, ILogger logger, - IConfiguration configuration) + IConfiguration configuration, + IMemoryCache memoryCache) { _httpClientFactory = httpClientFactory; _logger = logger; + _memoryCache = memoryCache; _downloadDirectory = configuration["PackageDownloadDirectory"] ?? Path.Combine(Path.GetTempPath(), "packages"); if (!Directory.Exists(_downloadDirectory)) @@ -21,12 +30,25 @@ public class PackageDownloadService : IPackageDownloadService } } - public async Task DownloadPackageAsync(string packageUrl) + public async Task GetOrDownloadPackageAsync(long missionId, string packageUrl) { - _logger.LogInformation("Downloading package from {Url}", packageUrl); + if (TryGetCachedFile(missionId, out var cachedFile)) + { + return cachedFile; + } + var missionLock = _locks.GetOrAdd(missionId, _ => new SemaphoreSlim(1, 1)); + + await missionLock.WaitAsync(); try { + if (TryGetCachedFile(missionId, out cachedFile)) + { + return cachedFile; + } + + _logger.LogInformation("Downloading package for mission {MissionId} from {Url}", missionId, packageUrl); + var httpClient = _httpClientFactory.CreateClient(); var response = await httpClient.GetAsync(packageUrl); response.EnsureSuccessStatusCode(); @@ -34,16 +56,92 @@ public class PackageDownloadService : IPackageDownloadService var fileName = $"package_{Guid.NewGuid()}.zip"; var filePath = Path.Combine(_downloadDirectory, fileName); - await using var fileStream = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.None); - await response.Content.CopyToAsync(fileStream); + try + { + await using var fileStream = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.None); + await response.Content.CopyToAsync(fileStream); + } + catch + { + if (File.Exists(filePath)) + { + try + { + File.Delete(filePath); + } + catch (Exception cleanupEx) + { + _logger.LogWarning(cleanupEx, "Failed to clean up temporary file {Path} after download error", filePath); + } + } + + throw; + } + + CacheFile(missionId, filePath); + + _logger.LogInformation("Package downloaded and cached for mission {MissionId} at {Path}", missionId, filePath); - _logger.LogInformation("Package downloaded successfully to {Path}", filePath); return filePath; } catch (Exception ex) { - _logger.LogError(ex, "Failed to download package from {Url}", packageUrl); + _logger.LogError(ex, "Failed to download package for mission {MissionId} from {Url}", missionId, packageUrl); throw; } + finally + { + missionLock.Release(); + } + } + + private bool TryGetCachedFile(long missionId, out string filePath) + { + if (_memoryCache.TryGetValue(missionId, out PackageCacheEntry? cacheEntry)) + { + if (cacheEntry is not null && File.Exists(cacheEntry.FilePath)) + { + filePath = cacheEntry.FilePath; + _logger.LogInformation("Using cached package for mission {MissionId} from {Path}", missionId, filePath); + return true; + } + + _memoryCache.Remove(missionId); + } + + filePath = string.Empty; + return false; + } + + private void CacheFile(long missionId, string filePath) + { + var cacheEntry = new PackageCacheEntry(filePath); + var options = new MemoryCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1) + }; + + options.RegisterPostEvictionCallback((_, value, _, _) => + { + if (value is not PackageCacheEntry entry) + { + return; + } + + try + { + if (File.Exists(entry.FilePath)) + { + File.Delete(entry.FilePath); + _logger.LogInformation("Removed cached package file {Path} after expiration", entry.FilePath); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to delete cached package file {Path} during eviction", entry.FilePath); + } + }); + + _memoryCache.Set(missionId, cacheEntry, options); } } diff --git a/src/LiquidCode.Tester.Gateway/Services/WorkerClientService.cs b/src/LiquidCode.Tester.Gateway/Services/WorkerClientService.cs index ca07a60..131d481 100644 --- a/src/LiquidCode.Tester.Gateway/Services/WorkerClientService.cs +++ b/src/LiquidCode.Tester.Gateway/Services/WorkerClientService.cs @@ -19,7 +19,7 @@ public class WorkerClientService : IWorkerClientService _configuration = configuration; } - public async Task SendToWorkerAsync(SubmitForTesterModel submit, string packagePath) + public async Task SendToWorkerAsync(SubmitForTesterModel submit, string packagePath, bool deletePackageAfterSend) { var workerUrl = GetWorkerUrlForLanguage(submit.Language); _logger.LogInformation("Sending submit {SubmitId} to worker at {WorkerUrl}", submit.Id, workerUrl); @@ -56,17 +56,20 @@ public class WorkerClientService : IWorkerClientService } finally { - // Clean up downloaded package - try + if (deletePackageAfterSend) { - if (File.Exists(packagePath)) + // Clean up package file when it is not needed anymore + try { - File.Delete(packagePath); + if (File.Exists(packagePath)) + { + File.Delete(packagePath); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to delete package file {Path}", packagePath); } - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to delete package file {Path}", packagePath); } } }