diff --git a/src/LiquidCode.Tester.Worker/Program.cs b/src/LiquidCode.Tester.Worker/Program.cs index 0a8e4bf..c97a24c 100644 --- a/src/LiquidCode.Tester.Worker/Program.cs +++ b/src/LiquidCode.Tester.Worker/Program.cs @@ -13,6 +13,7 @@ builder.Services.AddHttpClient(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/LiquidCode.Tester.Worker/Services/IPackageCacheService.cs b/src/LiquidCode.Tester.Worker/Services/IPackageCacheService.cs new file mode 100644 index 0000000..2e69b00 --- /dev/null +++ b/src/LiquidCode.Tester.Worker/Services/IPackageCacheService.cs @@ -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 GetOrAddAsync(string cacheKey, Func> factory); + ValueTask 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); +} diff --git a/src/LiquidCode.Tester.Worker/Services/PackageCacheService.cs b/src/LiquidCode.Tester.Worker/Services/PackageCacheService.cs new file mode 100644 index 0000000..d882a57 --- /dev/null +++ b/src/LiquidCode.Tester.Worker/Services/PackageCacheService.cs @@ -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>> _cache = new(StringComparer.Ordinal); + private readonly ConcurrentDictionary _locks = new(StringComparer.Ordinal); + private readonly ILogger _logger; + + public PackageCacheService(ILogger? logger = null) + { + _logger = logger ?? NullLogger.Instance; + } + + public async Task GetOrAddAsync(string cacheKey, Func> 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>(() => 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 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; + } +} diff --git a/src/LiquidCode.Tester.Worker/Services/TestingService.cs b/src/LiquidCode.Tester.Worker/Services/TestingService.cs index db37283..bedcb32 100644 --- a/src/LiquidCode.Tester.Worker/Services/TestingService.cs +++ b/src/LiquidCode.Tester.Worker/Services/TestingService.cs @@ -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 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 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 ParsePackageFromFileAsync(string packageFilePath) + { + await using var fileStream = File.OpenRead(packageFilePath); + return await _packageParser.ParsePackageAsync(fileStream); + } + + private async Task ComputePackageCacheKeyAsync(string packageFilePath, long missionId) + { + await using var stream = File.OpenRead(packageFilePath); + var hash = await ComputeHashAsync(stream); + return $"{missionId}:{hash}"; + } + + private static async Task 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( diff --git a/tests/LiquidCode.Tester.Worker.Tests/TestingServiceTests.cs b/tests/LiquidCode.Tester.Worker.Tests/TestingServiceTests.cs index e9025dc..21df6e3 100644 --- a/tests/LiquidCode.Tester.Worker.Tests/TestingServiceTests.cs +++ b/tests/LiquidCode.Tester.Worker.Tests/TestingServiceTests.cs @@ -15,6 +15,7 @@ public class TestingServiceTests : IDisposable private readonly Mock _outputCheckerMock; private readonly Mock _callbackServiceMock; private readonly Mock> _loggerMock; + private readonly IPackageCacheService _packageCache; private readonly TestingService _service; private readonly string _testDirectory; @@ -26,9 +27,11 @@ public class TestingServiceTests : IDisposable _outputCheckerMock = new Mock(); _callbackServiceMock = new Mock(); _loggerMock = new Mock>(); + _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 + { + new TestCase + { + Number = 1, + InputFilePath = inputFile, + OutputFilePath = outputFile, + TimeLimit = 2000, + MemoryLimit = 256 + } + } + }; + + var compilationService = new Mock(); + var executionService = new Mock(); + + var parseCalls = 0; + + _packageParserMock + .Setup(x => x.ParsePackageAsync(It.IsAny())) + .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(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new CompilationResult + { + Success = true, + ExecutablePath = executablePath + }); + + executionService + .Setup(x => x.ExecuteAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new ExecutionResult + { + Success = true, + Output = "expected output", + ExitCode = 0, + RuntimeError = false, + TimeLimitExceeded = false, + MemoryLimitExceeded = false + }); + + _outputCheckerMock + .Setup(x => x.CheckOutputWithCheckerAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .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()