Adds package caching to the testing service
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 1m31s
Build and Push Docker Images / build (src/LiquidCode.Tester.Worker/Dockerfile, git.nullptr.top/liquidcode/liquidcode-tester-worker-roman, worker) (push) Successful in 4m44s

Implements a package cache to avoid reparsing and extracting problem packages for subsequent submissions, improving performance and reducing resource consumption.

Introduces an interface and a concurrent dictionary-based implementation for the cache. A processing lock is also implemented using a semaphore to avoid concurrent access to the same package.
This commit is contained in:
2025-11-04 21:51:43 +03:00
parent bf7bd0ad6b
commit 8e6c2c222e
5 changed files with 309 additions and 39 deletions

View File

@@ -13,6 +13,7 @@ builder.Services.AddHttpClient();
builder.Services.AddSingleton<PolygonProblemXmlParser>();
builder.Services.AddSingleton<AnswerGenerationService>();
builder.Services.AddSingleton<CheckerService>();
builder.Services.AddSingleton<IPackageCacheService, PackageCacheService>();
builder.Services.AddSingleton<IPackageParserService, PackageParserService>();
builder.Services.AddSingleton<IOutputCheckerService, OutputCheckerService>();
builder.Services.AddSingleton<ICallbackService, CallbackService>();

View File

@@ -0,0 +1,18 @@
using System.Threading;
using System.Threading.Tasks;
using LiquidCode.Tester.Common.Models;
namespace LiquidCode.Tester.Worker.Services;
public interface IPackageCacheService
{
Task<CachedPackageResult> GetOrAddAsync(string cacheKey, Func<Task<ProblemPackage>> factory);
ValueTask<IAsyncDisposable> AcquireProcessingLockAsync(string cacheKey, CancellationToken cancellationToken = default);
void Invalidate(string cacheKey);
}
public sealed record CachedPackageResult(ProblemPackage Package, bool IsFromCache, string CacheKey)
{
public static CachedPackageResult FromNonCached(ProblemPackage package)
=> new(package, false, string.Empty);
}

View File

@@ -0,0 +1,111 @@
using System;
using System.Collections.Concurrent;
using System.Threading;
using System.Threading.Tasks;
using LiquidCode.Tester.Common.Models;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
namespace LiquidCode.Tester.Worker.Services;
public class PackageCacheService : IPackageCacheService
{
private readonly ConcurrentDictionary<string, Lazy<Task<ProblemPackage>>> _cache = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, SemaphoreSlim> _locks = new(StringComparer.Ordinal);
private readonly ILogger<PackageCacheService> _logger;
public PackageCacheService(ILogger<PackageCacheService>? logger = null)
{
_logger = logger ?? NullLogger<PackageCacheService>.Instance;
}
public async Task<CachedPackageResult> GetOrAddAsync(string cacheKey, Func<Task<ProblemPackage>> factory)
{
if (factory == null)
{
throw new ArgumentNullException(nameof(factory));
}
if (string.IsNullOrWhiteSpace(cacheKey))
{
var package = await factory().ConfigureAwait(false);
return CachedPackageResult.FromNonCached(package);
}
var lazyFactory = new Lazy<Task<ProblemPackage>>(() => factory(), LazyThreadSafetyMode.ExecutionAndPublication);
var lazy = _cache.GetOrAdd(cacheKey, lazyFactory);
var fromCache = lazy != lazyFactory;
try
{
var package = await lazy.Value.ConfigureAwait(false);
return new CachedPackageResult(package, fromCache, cacheKey);
}
catch
{
if (!fromCache)
{
_cache.TryRemove(cacheKey, out _);
}
throw;
}
}
public async ValueTask<IAsyncDisposable> AcquireProcessingLockAsync(string cacheKey, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(cacheKey))
{
return NoopAsyncDisposable.Instance;
}
var semaphore = _locks.GetOrAdd(cacheKey, _ => new SemaphoreSlim(1, 1));
await semaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
return new SemaphoreReleaser(semaphore);
}
public void Invalidate(string cacheKey)
{
if (string.IsNullOrWhiteSpace(cacheKey))
{
return;
}
if (_cache.TryRemove(cacheKey, out _))
{
_logger.LogInformation("Invalidated package cache entry {CacheKey}", cacheKey);
}
_locks.TryRemove(cacheKey, out _);
}
private sealed class SemaphoreReleaser : IAsyncDisposable
{
private readonly SemaphoreSlim _semaphore;
private bool _disposed;
public SemaphoreReleaser(SemaphoreSlim semaphore)
{
_semaphore = semaphore;
}
public ValueTask DisposeAsync()
{
if (_disposed)
{
return ValueTask.CompletedTask;
}
_semaphore.Release();
_disposed = true;
return ValueTask.CompletedTask;
}
}
private sealed class NoopAsyncDisposable : IAsyncDisposable
{
public static readonly NoopAsyncDisposable Instance = new();
private NoopAsyncDisposable() { }
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}

View File

@@ -1,3 +1,5 @@
using System.IO;
using System.Security.Cryptography;
using LiquidCode.Tester.Common.Models;
using LiquidCode.Tester.Worker.Controllers;
@@ -6,6 +8,7 @@ namespace LiquidCode.Tester.Worker.Services;
public class TestingService : ITestingService
{
private readonly IPackageParserService _packageParser;
private readonly IPackageCacheService _packageCache;
private readonly ICompilationServiceFactory _compilationServiceFactory;
private readonly IExecutionServiceFactory _executionServiceFactory;
private readonly IOutputCheckerService _outputChecker;
@@ -14,6 +17,7 @@ public class TestingService : ITestingService
public TestingService(
IPackageParserService packageParser,
IPackageCacheService packageCache,
ICompilationServiceFactory compilationServiceFactory,
IExecutionServiceFactory executionServiceFactory,
IOutputCheckerService outputChecker,
@@ -21,6 +25,7 @@ public class TestingService : ITestingService
ILogger<TestingService> logger)
{
_packageParser = packageParser;
_packageCache = packageCache;
_compilationServiceFactory = compilationServiceFactory;
_executionServiceFactory = executionServiceFactory;
_outputChecker = outputChecker;
@@ -32,66 +37,62 @@ public class TestingService : ITestingService
{
_logger.LogInformation("Starting to process submit {SubmitId}", request.Id);
string cleanupDirectory = string.Empty;
bool shouldCleanup = false;
void CleanupPackageIfNeeded()
{
if (shouldCleanup && !string.IsNullOrEmpty(cleanupDirectory))
{
CleanupWorkingDirectory(cleanupDirectory);
}
}
try
{
// Send initial status
await SendStatusAsync(request, State.Waiting, ErrorCode.None, "Submit received", 0, 0);
// Parse package
ProblemPackage package;
if (!string.IsNullOrEmpty(request.PackageFilePath))
{
// Use saved file path (from background task)
await using var fileStream = File.OpenRead(request.PackageFilePath);
package = await _packageParser.ParsePackageAsync(fileStream);
}
else if (request.Package != null)
{
// Use IFormFile directly (should not happen in background tasks)
using var packageStream = request.Package.OpenReadStream();
package = await _packageParser.ParsePackageAsync(packageStream);
}
else
{
throw new InvalidOperationException("No package provided");
}
var packageResult = await GetPackageAsync(request);
var package = packageResult.Package;
cleanupDirectory = package.ExtractionRoot ?? package.WorkingDirectory;
shouldCleanup = string.IsNullOrEmpty(packageResult.CacheKey);
await using var packageProcessingLock = await _packageCache.AcquireProcessingLockAsync(packageResult.CacheKey);
_logger.LogInformation("Package parsed, found {TestCount} tests", package.TestCases.Count);
// Validate that package contains test cases
if (package.TestCases.Count == 0)
{
_logger.LogError("No test cases found in package for submit {SubmitId}", request.Id);
await SendStatusAsync(request, State.Done, ErrorCode.UnknownError,
"No test cases found in package", 0, 0);
CleanupWorkingDirectory(package.ExtractionRoot ?? package.WorkingDirectory);
CleanupPackageIfNeeded();
return;
}
// Send compiling status
await SendStatusAsync(request, State.Compiling, ErrorCode.None, "Compiling solution", 0, package.TestCases.Count);
// Get language-specific services
var compilationService = _compilationServiceFactory.GetCompilationService(request.Language);
var executionService = _executionServiceFactory.GetExecutionService(request.Language);
// Compile user solution
var compilationResult = await compilationService.CompileAsync(request.SourceCode, package.WorkingDirectory, request.LanguageVersion);
var compilationResult = await compilationService.CompileAsync(
request.SourceCode,
package.WorkingDirectory,
request.LanguageVersion);
if (!compilationResult.Success)
{
_logger.LogWarning("Compilation failed for submit {SubmitId}", request.Id);
await SendStatusAsync(request, State.Done, ErrorCode.CompileError,
$"Compilation failed: {compilationResult.CompilerOutput}", 0, package.TestCases.Count);
CleanupPackageIfNeeded();
return;
}
_logger.LogInformation("Compilation successful");
// Send testing status
await SendStatusAsync(request, State.Testing, ErrorCode.None, "Running tests", 0, package.TestCases.Count);
// Run tests
for (int i = 0; i < package.TestCases.Count; i++)
{
var testCase = package.TestCases[i];
@@ -100,20 +101,18 @@ public class TestingService : ITestingService
await SendStatusAsync(request, State.Testing, ErrorCode.None,
$"Running test {testCase.Number}", testCase.Number, package.TestCases.Count);
// Execute solution
var executionResult = await executionService.ExecuteAsync(
compilationResult.ExecutablePath!,
testCase.InputFilePath,
testCase.TimeLimit,
testCase.MemoryLimit);
// Check for execution errors
if (executionResult.TimeLimitExceeded)
{
_logger.LogWarning("Time limit exceeded on test {TestNumber}", testCase.Number);
await SendStatusAsync(request, State.Done, ErrorCode.TimeLimitError,
$"Time limit exceeded on test {testCase.Number}", testCase.Number, package.TestCases.Count);
CleanupWorkingDirectory(package.ExtractionRoot ?? package.WorkingDirectory);
CleanupPackageIfNeeded();
return;
}
@@ -122,7 +121,7 @@ public class TestingService : ITestingService
_logger.LogWarning("Memory limit exceeded on test {TestNumber}", testCase.Number);
await SendStatusAsync(request, State.Done, ErrorCode.MemoryError,
$"Memory limit exceeded on test {testCase.Number}", testCase.Number, package.TestCases.Count);
CleanupWorkingDirectory(package.ExtractionRoot ?? package.WorkingDirectory);
CleanupPackageIfNeeded();
return;
}
@@ -131,11 +130,10 @@ public class TestingService : ITestingService
_logger.LogWarning("Runtime error on test {TestNumber}: {Error}", testCase.Number, executionResult.ErrorMessage);
await SendStatusAsync(request, State.Done, ErrorCode.RuntimeError,
$"Runtime error on test {testCase.Number}: {executionResult.ErrorMessage}", testCase.Number, package.TestCases.Count);
CleanupWorkingDirectory(package.ExtractionRoot ?? package.WorkingDirectory);
CleanupPackageIfNeeded();
return;
}
// Check output (using custom checker if available)
var outputCorrect = await _outputChecker.CheckOutputWithCheckerAsync(
executionResult.Output,
testCase.InputFilePath,
@@ -147,29 +145,71 @@ public class TestingService : ITestingService
_logger.LogWarning("Wrong answer on test {TestNumber}", testCase.Number);
await SendStatusAsync(request, State.Done, ErrorCode.IncorrectAnswer,
$"Wrong answer on test {testCase.Number}", testCase.Number, package.TestCases.Count);
CleanupWorkingDirectory(package.ExtractionRoot ?? package.WorkingDirectory);
CleanupPackageIfNeeded();
return;
}
_logger.LogInformation("Test {TestNumber} passed", testCase.Number);
}
// All tests passed!
_logger.LogInformation("All tests passed for submit {SubmitId}", request.Id);
await SendStatusAsync(request, State.Done, ErrorCode.None,
"All tests passed", package.TestCases.Count, package.TestCases.Count);
// Cleanup
CleanupWorkingDirectory(package.ExtractionRoot ?? package.WorkingDirectory);
CleanupPackageIfNeeded();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing submit {SubmitId}", request.Id);
await SendStatusAsync(request, State.Done, ErrorCode.UnknownError,
$"Internal error: {ex.Message}", 0, 0);
CleanupPackageIfNeeded();
}
}
private async Task<CachedPackageResult> GetPackageAsync(TestRequest request)
{
if (!string.IsNullOrEmpty(request.PackageFilePath))
{
if (!File.Exists(request.PackageFilePath))
{
throw new FileNotFoundException($"Package file not found at {request.PackageFilePath}");
}
var cacheKey = await ComputePackageCacheKeyAsync(request.PackageFilePath, request.MissionId);
return await _packageCache.GetOrAddAsync(cacheKey, () => ParsePackageFromFileAsync(request.PackageFilePath));
}
if (request.Package != null)
{
await using var packageStream = request.Package.OpenReadStream();
var package = await _packageParser.ParsePackageAsync(packageStream);
return CachedPackageResult.FromNonCached(package);
}
throw new InvalidOperationException("No package provided");
}
private async Task<ProblemPackage> ParsePackageFromFileAsync(string packageFilePath)
{
await using var fileStream = File.OpenRead(packageFilePath);
return await _packageParser.ParsePackageAsync(fileStream);
}
private async Task<string> ComputePackageCacheKeyAsync(string packageFilePath, long missionId)
{
await using var stream = File.OpenRead(packageFilePath);
var hash = await ComputeHashAsync(stream);
return $"{missionId}:{hash}";
}
private static async Task<string> ComputeHashAsync(Stream stream)
{
using var sha256 = SHA256.Create();
var hash = await sha256.ComputeHashAsync(stream);
return Convert.ToHexString(hash);
}
private async Task SendStatusAsync(TestRequest request, State state, ErrorCode errorCode, string message, int currentTest, int totalTests)
{
var response = new TesterResponseModel(

View File

@@ -15,6 +15,7 @@ public class TestingServiceTests : IDisposable
private readonly Mock<IOutputCheckerService> _outputCheckerMock;
private readonly Mock<ICallbackService> _callbackServiceMock;
private readonly Mock<ILogger<TestingService>> _loggerMock;
private readonly IPackageCacheService _packageCache;
private readonly TestingService _service;
private readonly string _testDirectory;
@@ -26,9 +27,11 @@ public class TestingServiceTests : IDisposable
_outputCheckerMock = new Mock<IOutputCheckerService>();
_callbackServiceMock = new Mock<ICallbackService>();
_loggerMock = new Mock<ILogger<TestingService>>();
_packageCache = new PackageCacheService();
_service = new TestingService(
_packageParserMock.Object,
_packageCache,
_compilationFactoryMock.Object,
_executionFactoryMock.Object,
_outputCheckerMock.Object,
@@ -104,7 +107,7 @@ public class TestingServiceTests : IDisposable
await File.WriteAllTextAsync(inputFile, "test input");
await File.WriteAllTextAsync(outputFile, "expected output");
await File.WriteAllTextAsync(executablePath, "dummy");
await CreateEmptyPackage(packageFilePath);
await CreateEmptyPackage(packageFilePath);
var request = new TestRequest
{
@@ -194,11 +197,108 @@ public class TestingServiceTests : IDisposable
);
}
private async Task CreateEmptyPackage(string filePath)
[Fact]
public async Task ProcessSubmitAsync_ReusesCachedPackage()
{
// Arrange
var packageFilePath = Path.Combine(_testDirectory, "cached_package.zip");
var inputFile = Path.Combine(_testDirectory, "1.in");
var outputFile = Path.Combine(_testDirectory, "1.out");
var executablePath = Path.Combine(_testDirectory, "solution.exe");
await File.WriteAllTextAsync(inputFile, "test input");
await File.WriteAllTextAsync(outputFile, "expected output");
await File.WriteAllTextAsync(executablePath, "dummy");
await CreateEmptyPackage(packageFilePath);
var request = new TestRequest
{
Id = 123,
MissionId = 456,
Language = "cpp",
LanguageVersion = "17",
SourceCode = "int main() { return 0; }",
PackageFilePath = packageFilePath,
CallbackUrl = "http://localhost/callback"
};
var package = new ProblemPackage
{
WorkingDirectory = _testDirectory,
ExtractionRoot = _testDirectory,
TestCases = new List<TestCase>
{
new TestCase
{
Number = 1,
InputFilePath = inputFile,
OutputFilePath = outputFile,
TimeLimit = 2000,
MemoryLimit = 256
}
}
};
var compilationService = new Mock<ICompilationService>();
var executionService = new Mock<IExecutionService>();
var parseCalls = 0;
_packageParserMock
.Setup(x => x.ParsePackageAsync(It.IsAny<Stream>()))
.Callback(() => parseCalls++)
.ReturnsAsync(package);
_compilationFactoryMock
.Setup(x => x.GetCompilationService("cpp"))
.Returns(compilationService.Object);
_executionFactoryMock
.Setup(x => x.GetExecutionService("cpp"))
.Returns(executionService.Object);
compilationService
.Setup(x => x.CompileAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()))
.ReturnsAsync(new CompilationResult
{
Success = true,
ExecutablePath = executablePath
});
executionService
.Setup(x => x.ExecuteAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>(), It.IsAny<int>()))
.ReturnsAsync(new ExecutionResult
{
Success = true,
Output = "expected output",
ExitCode = 0,
RuntimeError = false,
TimeLimitExceeded = false,
MemoryLimitExceeded = false
});
_outputCheckerMock
.Setup(x => x.CheckOutputWithCheckerAsync(
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<string?>()))
.ReturnsAsync(true);
// Act
await _service.ProcessSubmitAsync(request);
await _service.ProcessSubmitAsync(request);
// Assert
Assert.Equal(1, parseCalls);
}
private Task CreateEmptyPackage(string filePath)
{
using var fileStream = File.Create(filePath);
using var archive = new ZipArchive(fileStream, ZipArchiveMode.Create);
// Create empty archive
return Task.CompletedTask;
}
public void Dispose()