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); } } }