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
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:
@@ -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>();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
111
src/LiquidCode.Tester.Worker/Services/PackageCacheService.cs
Normal file
111
src/LiquidCode.Tester.Worker/Services/PackageCacheService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user