Add upload cleanup functionality and improve error handling for media uploads
ci / build-backend (push) Successful in 2m3s
ci / build-frontend (push) Successful in 44s
ci / tests (push) Successful in 2m39s
ci / sonar (push) Successful in 5m50s

Implemented a new background service, UploadsCleanupBackgroundService, to manage stale uploads in the uploads directory. Enhanced error handling in the media upload process to ensure incomplete uploads are deleted immediately upon failure, preventing unnecessary storage usage. Updated the .env.example and StorageOptions to include configuration for stale upload retention time. Improved documentation to reflect these changes and clarify the upload process.
This commit is contained in:
Leonid Pershin
2026-07-27 02:56:15 +03:00
parent 986f7ff52d
commit a8b5a8f693
11 changed files with 301 additions and 17 deletions
@@ -0,0 +1,121 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using TeleWave.Infrastructure.Media;
using Xunit;
namespace TeleWave.Application.Tests.Media;
/// <summary>
/// Уборка перевалочного каталога: в <c>uploads/</c> не должно оставаться файлов, на которые никто
/// не сослался — это гигабайты на диске, невидимые в админке.
/// </summary>
public class UploadsCleanupTests : IDisposable
{
private readonly string _root = Path.Combine(
Path.GetTempPath(),
"telewave-tests",
Guid.NewGuid().ToString("N")
);
private MediaPathResolver Paths() =>
new(Options.Create(new StorageOptions { RootPath = _root }));
[Fact]
public async Task DeletesPartialUpload_WhenStreamFails()
{
var paths = Paths();
paths.EnsureDirectories();
var storage = new FileSystemMediaStorage(paths);
await Assert.ThrowsAsync<IOException>(() =>
storage.SaveUploadAsync(new FailingStream(), ".mkv", CancellationToken.None)
);
Assert.Empty(Directory.GetFiles(paths.UploadsDir));
}
[Fact]
public void Sweep_DeletesStaleFilesOnly()
{
var paths = Paths();
paths.EnsureDirectories();
var stale = Path.Combine(paths.UploadsDir, "stale.mkv");
var fresh = Path.Combine(paths.UploadsDir, "fresh.mkv");
// Идущую заливку от брошенного файла отличает именно время последней записи.
WriteAged(stale, TimeSpan.FromHours(25));
WriteAged(fresh, TimeSpan.Zero);
UploadsCleaner.Sweep(paths, TimeSpan.FromHours(24), NullLogger.Instance);
Assert.False(File.Exists(stale));
Assert.True(File.Exists(fresh));
}
[Fact]
public void Sweep_SurvivesMissingDirectory()
{
// Каталога может не быть на самом первом старте — это не повод падать.
UploadsCleaner.Sweep(Paths(), TimeSpan.FromHours(24), NullLogger.Instance);
}
/// <summary>
/// Кладёт файл и «состаривает» его. Отметку перепроверяем: Windows дописывает время последней
/// записи при закрытии handle и может затереть выставленное сразу после записи.
/// </summary>
private static void WriteAged(string path, TimeSpan age)
{
File.WriteAllText(path, "x");
if (age == TimeSpan.Zero)
return;
var stamp = DateTime.UtcNow - age;
for (var attempt = 0; attempt < 20; attempt++)
{
File.SetLastWriteTimeUtc(path, stamp);
if ((File.GetLastWriteTimeUtc(path) - stamp).Duration() < TimeSpan.FromSeconds(1))
return;
Thread.Sleep(20);
}
Assert.Fail("Не удалось выставить файлу время последней записи.");
}
public void Dispose()
{
GC.SuppressFinalize(this);
if (Directory.Exists(_root))
Directory.Delete(_root, recursive: true);
}
/// <summary>Поток, обрывающийся на середине, — имитация отвалившегося клиента.</summary>
private sealed class FailingStream : Stream
{
public override bool CanRead => true;
public override bool CanSeek => false;
public override bool CanWrite => false;
public override long Length => throw new NotSupportedException();
public override long Position
{
get => throw new NotSupportedException();
set => throw new NotSupportedException();
}
public override int Read(byte[] buffer, int offset, int count) =>
throw new IOException("Соединение разорвано.");
public override ValueTask<int> ReadAsync(
Memory<byte> buffer,
CancellationToken cancellationToken = default
) => throw new IOException("Соединение разорвано.");
public override void Flush() { }
public override long Seek(long offset, SeekOrigin origin) =>
throw new NotSupportedException();
public override void SetLength(long value) => throw new NotSupportedException();
public override void Write(byte[] buffer, int offset, int count) =>
throw new NotSupportedException();
}
}