Improves package download and caching
All checks were successful
Build and Push Docker Images / build (src/LiquidCode.Tester.Gateway/Dockerfile, git.nullptr.top/liquidcode/liquidcode-tester-gateway-roman, gateway) (push) Successful in 53s
Build and Push Docker Images / build (src/LiquidCode.Tester.Worker/Dockerfile, git.nullptr.top/liquidcode/liquidcode-tester-worker-roman, worker) (push) Successful in 50s

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.
This commit is contained in:
2025-11-02 20:05:50 +03:00
parent b95d94d796
commit bf7bd0ad6b
6 changed files with 130 additions and 26 deletions

View File

@@ -30,11 +30,11 @@ public class TesterController : ControllerBase
try try
{ {
// Download the package // Download the package or use cached version if available
var packagePath = await _packageDownloadService.DownloadPackageAsync(request.PackageUrl); var packagePath = await _packageDownloadService.GetOrDownloadPackageAsync(request.MissionId, request.PackageUrl);
// Send to appropriate worker based on language // 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 }); 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 // 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 }); return Accepted(new { message = "Submit accepted for testing", submitId = request.Id });
} }

View File

@@ -10,6 +10,7 @@ builder.Services.AddOpenApi();
// Add HttpClient // Add HttpClient
builder.Services.AddHttpClient(); builder.Services.AddHttpClient();
builder.Services.AddMemoryCache();
// Register application services // Register application services
builder.Services.AddSingleton<IPackageDownloadService, PackageDownloadService>(); builder.Services.AddSingleton<IPackageDownloadService, PackageDownloadService>();

View File

@@ -3,9 +3,10 @@ namespace LiquidCode.Tester.Gateway.Services;
public interface IPackageDownloadService public interface IPackageDownloadService
{ {
/// <summary> /// <summary>
/// Downloads a package from the specified URL /// Retrieves a cached package for the mission or downloads it if missing.
/// </summary> /// </summary>
/// <param name="packageUrl">URL to download the package from</param> /// <param name="missionId">Unique mission identifier used as cache key.</param>
/// <returns>Path to the downloaded package file</returns> /// <param name="packageUrl">URL to download the package from when cache is cold.</param>
Task<string> DownloadPackageAsync(string packageUrl); /// <returns>Path to the cached or downloaded package file.</returns>
Task<string> GetOrDownloadPackageAsync(long missionId, string packageUrl);
} }

View File

@@ -8,6 +8,7 @@ public interface IWorkerClientService
/// Sends a submit to the appropriate worker based on the language /// Sends a submit to the appropriate worker based on the language
/// </summary> /// </summary>
/// <param name="submit">Submit data</param> /// <param name="submit">Submit data</param>
/// <param name="packagePath">Local path to the downloaded package</param> /// <param name="packagePath">Local path to the package that will be streamed to the worker</param>
Task SendToWorkerAsync(SubmitForTesterModel submit, string packagePath); /// <param name="deletePackageAfterSend">Indicates whether the package file should be removed after upload</param>
Task SendToWorkerAsync(SubmitForTesterModel submit, string packagePath, bool deletePackageAfterSend);
} }

View File

@@ -1,3 +1,6 @@
using System.Collections.Concurrent;
using Microsoft.Extensions.Caching.Memory;
namespace LiquidCode.Tester.Gateway.Services; namespace LiquidCode.Tester.Gateway.Services;
public class PackageDownloadService : IPackageDownloadService public class PackageDownloadService : IPackageDownloadService
@@ -5,14 +8,20 @@ public class PackageDownloadService : IPackageDownloadService
private readonly IHttpClientFactory _httpClientFactory; private readonly IHttpClientFactory _httpClientFactory;
private readonly ILogger<PackageDownloadService> _logger; private readonly ILogger<PackageDownloadService> _logger;
private readonly string _downloadDirectory; private readonly string _downloadDirectory;
private readonly IMemoryCache _memoryCache;
private readonly ConcurrentDictionary<long, SemaphoreSlim> _locks = new();
private sealed record PackageCacheEntry(string FilePath);
public PackageDownloadService( public PackageDownloadService(
IHttpClientFactory httpClientFactory, IHttpClientFactory httpClientFactory,
ILogger<PackageDownloadService> logger, ILogger<PackageDownloadService> logger,
IConfiguration configuration) IConfiguration configuration,
IMemoryCache memoryCache)
{ {
_httpClientFactory = httpClientFactory; _httpClientFactory = httpClientFactory;
_logger = logger; _logger = logger;
_memoryCache = memoryCache;
_downloadDirectory = configuration["PackageDownloadDirectory"] ?? Path.Combine(Path.GetTempPath(), "packages"); _downloadDirectory = configuration["PackageDownloadDirectory"] ?? Path.Combine(Path.GetTempPath(), "packages");
if (!Directory.Exists(_downloadDirectory)) if (!Directory.Exists(_downloadDirectory))
@@ -21,12 +30,25 @@ public class PackageDownloadService : IPackageDownloadService
} }
} }
public async Task<string> DownloadPackageAsync(string packageUrl) public async Task<string> 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 try
{ {
if (TryGetCachedFile(missionId, out cachedFile))
{
return cachedFile;
}
_logger.LogInformation("Downloading package for mission {MissionId} from {Url}", missionId, packageUrl);
var httpClient = _httpClientFactory.CreateClient(); var httpClient = _httpClientFactory.CreateClient();
var response = await httpClient.GetAsync(packageUrl); var response = await httpClient.GetAsync(packageUrl);
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
@@ -34,16 +56,92 @@ public class PackageDownloadService : IPackageDownloadService
var fileName = $"package_{Guid.NewGuid()}.zip"; var fileName = $"package_{Guid.NewGuid()}.zip";
var filePath = Path.Combine(_downloadDirectory, fileName); var filePath = Path.Combine(_downloadDirectory, fileName);
await using var fileStream = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.None); try
await response.Content.CopyToAsync(fileStream); {
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; return filePath;
} }
catch (Exception ex) 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; 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);
} }
} }

View File

@@ -19,7 +19,7 @@ public class WorkerClientService : IWorkerClientService
_configuration = configuration; _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); var workerUrl = GetWorkerUrlForLanguage(submit.Language);
_logger.LogInformation("Sending submit {SubmitId} to worker at {WorkerUrl}", submit.Id, workerUrl); _logger.LogInformation("Sending submit {SubmitId} to worker at {WorkerUrl}", submit.Id, workerUrl);
@@ -56,17 +56,20 @@ public class WorkerClientService : IWorkerClientService
} }
finally finally
{ {
// Clean up downloaded package if (deletePackageAfterSend)
try
{ {
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);
} }
} }
} }