Add upload cleanup functionality and improve error handling for media uploads
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:
@@ -88,13 +88,27 @@ public static class MediaEndpoints
|
||||
var extension = Path.GetExtension(fileName);
|
||||
var token = await storage.SaveUploadAsync(request.Body, extension, cancellationToken);
|
||||
|
||||
var result = await sender.Send(
|
||||
new RegisterMediaAssetCommand(token, MediaSource.Upload, fileName),
|
||||
cancellationToken
|
||||
);
|
||||
// Файл уже на диске в uploads/, но в БД его ещё нет и ссылок на токен нигде не остаётся:
|
||||
// любой исход, кроме успешной регистрации, обязан за собой убрать. Удаляем и при исключении
|
||||
// (оборванный запрос, сбой БД) — иначе гигабайты остаются в uploads/ до уборщика.
|
||||
// Токен отмены для уборки не пробрасываем: при обрыве запроса он уже сработал.
|
||||
Result<Guid> result;
|
||||
try
|
||||
{
|
||||
result = await sender.Send(
|
||||
new RegisterMediaAssetCommand(token, MediaSource.Upload, fileName),
|
||||
cancellationToken
|
||||
);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await storage.DeleteUploadAsync(token, CancellationToken.None);
|
||||
throw;
|
||||
}
|
||||
|
||||
if (!result.IsSuccess)
|
||||
{
|
||||
await storage.DeleteUploadAsync(token, cancellationToken);
|
||||
await storage.DeleteUploadAsync(token, CancellationToken.None);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
|
||||
@@ -163,6 +163,7 @@ public static class DependencyInjection
|
||||
|
||||
services.AddHostedService<MediaProcessingBackgroundService>();
|
||||
services.AddHostedService<InboxScannerBackgroundService>();
|
||||
services.AddHostedService<UploadsCleanupBackgroundService>();
|
||||
services.AddHostedService<BumperRenderBackgroundService>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,18 +32,43 @@ public sealed class FileSystemMediaStorage(MediaPathResolver paths) : IMediaStor
|
||||
var token = Guid.NewGuid().ToString("N") + extension.ToLowerInvariant();
|
||||
var path = paths.UploadPath(token);
|
||||
|
||||
await using var file = new FileStream(
|
||||
path,
|
||||
FileMode.CreateNew,
|
||||
FileAccess.Write,
|
||||
FileShare.None,
|
||||
CopyBufferSize,
|
||||
useAsync: true
|
||||
);
|
||||
await content.CopyToAsync(file, CopyBufferSize, cancellationToken);
|
||||
try
|
||||
{
|
||||
await using var file = new FileStream(
|
||||
path,
|
||||
FileMode.CreateNew,
|
||||
FileAccess.Write,
|
||||
FileShare.None,
|
||||
CopyBufferSize,
|
||||
useAsync: true
|
||||
);
|
||||
await content.CopyToAsync(file, CopyBufferSize, cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Заливка оборвалась (клиент отвалился, отмена, диск) — недописанный файл никому не
|
||||
// нужен: токен наружу не ушёл, и подобрать его некому. Сносим сразу, а не оставляем
|
||||
// уборщику: это гигабайты. Поток к этому моменту уже закрыт — using вышел из области.
|
||||
TryDelete(path);
|
||||
throw;
|
||||
}
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
private static void TryDelete(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(path))
|
||||
File.Delete(path);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// Не наш день: файл заперт. Подберёт UploadsCleanupBackgroundService.
|
||||
}
|
||||
}
|
||||
|
||||
public Task DeleteUploadAsync(string uploadToken, CancellationToken cancellationToken)
|
||||
{
|
||||
var path = paths.UploadPath(uploadToken);
|
||||
|
||||
@@ -15,4 +15,12 @@ public sealed class StorageOptions
|
||||
|
||||
/// <summary>Порог свободного места, ниже которого загрузка отклоняется, байт.</summary>
|
||||
public long MinFreeSpaceBytes { get; init; } = 10L * 1024 * 1024 * 1024;
|
||||
|
||||
/// <summary>
|
||||
/// Через сколько часов простоя удалять брошенный файл в <c>uploads/</c>. Каталог перевалочный:
|
||||
/// после регистрации файл уходит в <c>originals/</c>, и всё, что там залежалось, — след от
|
||||
/// оборванной заливки или аварийного рестарта. Идущую заливку порог не задевает: она правит
|
||||
/// файл, и отсчёт идёт от времени последней записи. 0 — уборку не делать.
|
||||
/// </summary>
|
||||
public int StaleUploadHours { get; init; } = 24;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace TeleWave.Infrastructure.Media;
|
||||
|
||||
/// <summary>
|
||||
/// Уборка перевалочного каталога <c>uploads/</c>: удаляет файлы, которых не касались дольше порога.
|
||||
/// После регистрации ассета файл уходит в <c>originals/</c>, а токен загрузки нигде не хранится —
|
||||
/// значит, всё залежавшееся в <c>uploads/</c> это след оборванной заливки или аварийного рестарта,
|
||||
/// и подобрать его некому.
|
||||
/// </summary>
|
||||
public static class UploadsCleaner
|
||||
{
|
||||
/// <summary>
|
||||
/// Один проход. Порог считается от времени последней записи, а не создания: идущая многочасовая
|
||||
/// заливка правит файл и под него не попадает.
|
||||
/// </summary>
|
||||
public static void Sweep(MediaPathResolver paths, TimeSpan retention, ILogger logger)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!Directory.Exists(paths.UploadsDir))
|
||||
return;
|
||||
|
||||
var deadline = DateTime.UtcNow - retention;
|
||||
foreach (var path in Directory.EnumerateFiles(paths.UploadsDir))
|
||||
DeleteIfStale(path, deadline, logger);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Ошибка уборки uploads/");
|
||||
}
|
||||
}
|
||||
|
||||
private static void DeleteIfStale(string path, DateTime deadline, ILogger logger)
|
||||
{
|
||||
try
|
||||
{
|
||||
var file = new FileInfo(path);
|
||||
if (!file.Exists || file.LastWriteTimeUtc > deadline)
|
||||
return;
|
||||
|
||||
// Метрики снимаем до удаления: после него FileInfo их уже не отдаст.
|
||||
var size = file.Length;
|
||||
var lastWrite = file.LastWriteTimeUtc;
|
||||
file.Delete();
|
||||
logger.LogInformation(
|
||||
"Удалён брошенный файл загрузки {Name} ({Size} байт, последняя запись {LastWrite:u})",
|
||||
file.Name,
|
||||
size,
|
||||
lastWrite
|
||||
);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// Файл занят или права не те — попробуем на следующем тике.
|
||||
logger.LogWarning(ex, "Не удалось удалить файл загрузки {Path}", path);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace TeleWave.Infrastructure.Media;
|
||||
|
||||
/// <summary>
|
||||
/// Расписание уборки <c>uploads/</c> (сама уборка — <see cref="UploadsCleaner"/>): проход на старте
|
||||
/// и раз в час. Удаление недописанного файла на месте
|
||||
/// (<see cref="FileSystemMediaStorage.SaveUploadAsync"/>) закрывает штатные обрывы, этот сервис —
|
||||
/// страховка от аварийного рестарта посреди заливки.
|
||||
/// </summary>
|
||||
public sealed class UploadsCleanupBackgroundService(
|
||||
MediaPathResolver paths,
|
||||
IOptions<StorageOptions> options,
|
||||
ILogger<UploadsCleanupBackgroundService> logger
|
||||
) : BackgroundService
|
||||
{
|
||||
/// <summary>Как часто заглядывать в каталог. Порог измеряется часами — чаще незачем.</summary>
|
||||
private static readonly TimeSpan Period = TimeSpan.FromHours(1);
|
||||
|
||||
private readonly StorageOptions _storage = options.Value;
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
if (_storage.StaleUploadHours <= 0)
|
||||
{
|
||||
logger.LogInformation("Уборка uploads/ выключена (Storage:StaleUploadHours = 0).");
|
||||
return;
|
||||
}
|
||||
|
||||
var retention = TimeSpan.FromHours(_storage.StaleUploadHours);
|
||||
paths.EnsureDirectories();
|
||||
|
||||
// Первый проход сразу: после аварийного рестарта недописанное лежит с прошлого запуска.
|
||||
UploadsCleaner.Sweep(paths, retention, logger);
|
||||
|
||||
using var timer = new PeriodicTimer(Period);
|
||||
while (await timer.WaitForNextTickAsync(stoppingToken))
|
||||
UploadsCleaner.Sweep(paths, retention, logger);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user