From a8b5a8f693f5de89731701b6e27b9ba3b585567f Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Mon, 27 Jul 2026 02:56:15 +0300 Subject: [PATCH] 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. --- .env.example | 3 + .../TeleWave.Api/Endpoints/MediaEndpoints.cs | 24 +++- .../DependencyInjection.cs | 1 + .../Media/FileSystemMediaStorage.cs | 43 +++++-- .../Media/StorageOptions.cs | 8 ++ .../Media/UploadsCleaner.cs | 59 +++++++++ .../Media/UploadsCleanupBackgroundService.cs | 42 ++++++ .../Media/UploadsCleanupTests.cs | 121 ++++++++++++++++++ docs/media-storage-and-streaming.md | 10 +- .../src/features/admin/shows/ShowDetail.tsx | 4 +- .../src/features/admin/shows/ShowsPanel.tsx | 3 +- 11 files changed, 301 insertions(+), 17 deletions(-) create mode 100644 backend/src/TeleWave.Infrastructure/Media/UploadsCleaner.cs create mode 100644 backend/src/TeleWave.Infrastructure/Media/UploadsCleanupBackgroundService.cs create mode 100644 backend/tests/TeleWave.Application.Tests/Media/UploadsCleanupTests.cs diff --git a/.env.example b/.env.example index 0448261..f93428c 100644 --- a/.env.example +++ b/.env.example @@ -32,6 +32,9 @@ Storage__LiveWindowSegments=10 Storage__KeepOriginals=false # Порог свободного места, ниже которого загрузка отклоняется (10 ГБ). Storage__MinFreeSpaceBytes=10737418240 +# Через сколько часов простоя убирать брошенное в uploads/ (след оборванной заливки или рестарта +# посреди неё). Идущую загрузку не задевает — отсчёт от времени последней записи. 0 — не убирать. +# Storage__StaleUploadHours=24 # TTL stream-токена (cookie tw_stream), минуты. Короткий срок ограничивает окно доступа после # блокировки/логаута; фронт перевыпускает cookie, пока идёт просмотр. По умолчанию 30. # Storage__StreamTokenMinutes=30 diff --git a/backend/src/TeleWave.Api/Endpoints/MediaEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/MediaEndpoints.cs index 5d71fc4..55a5d59 100644 --- a/backend/src/TeleWave.Api/Endpoints/MediaEndpoints.cs +++ b/backend/src/TeleWave.Api/Endpoints/MediaEndpoints.cs @@ -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 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(); } diff --git a/backend/src/TeleWave.Infrastructure/DependencyInjection.cs b/backend/src/TeleWave.Infrastructure/DependencyInjection.cs index 467fd1d..f0d0dca 100644 --- a/backend/src/TeleWave.Infrastructure/DependencyInjection.cs +++ b/backend/src/TeleWave.Infrastructure/DependencyInjection.cs @@ -163,6 +163,7 @@ public static class DependencyInjection services.AddHostedService(); services.AddHostedService(); + services.AddHostedService(); services.AddHostedService(); } } diff --git a/backend/src/TeleWave.Infrastructure/Media/FileSystemMediaStorage.cs b/backend/src/TeleWave.Infrastructure/Media/FileSystemMediaStorage.cs index 1266b2f..e3be26f 100644 --- a/backend/src/TeleWave.Infrastructure/Media/FileSystemMediaStorage.cs +++ b/backend/src/TeleWave.Infrastructure/Media/FileSystemMediaStorage.cs @@ -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); diff --git a/backend/src/TeleWave.Infrastructure/Media/StorageOptions.cs b/backend/src/TeleWave.Infrastructure/Media/StorageOptions.cs index 80d3996..be8b08b 100644 --- a/backend/src/TeleWave.Infrastructure/Media/StorageOptions.cs +++ b/backend/src/TeleWave.Infrastructure/Media/StorageOptions.cs @@ -15,4 +15,12 @@ public sealed class StorageOptions /// Порог свободного места, ниже которого загрузка отклоняется, байт. public long MinFreeSpaceBytes { get; init; } = 10L * 1024 * 1024 * 1024; + + /// + /// Через сколько часов простоя удалять брошенный файл в uploads/. Каталог перевалочный: + /// после регистрации файл уходит в originals/, и всё, что там залежалось, — след от + /// оборванной заливки или аварийного рестарта. Идущую заливку порог не задевает: она правит + /// файл, и отсчёт идёт от времени последней записи. 0 — уборку не делать. + /// + public int StaleUploadHours { get; init; } = 24; } diff --git a/backend/src/TeleWave.Infrastructure/Media/UploadsCleaner.cs b/backend/src/TeleWave.Infrastructure/Media/UploadsCleaner.cs new file mode 100644 index 0000000..a1dddc2 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Media/UploadsCleaner.cs @@ -0,0 +1,59 @@ +using Microsoft.Extensions.Logging; + +namespace TeleWave.Infrastructure.Media; + +/// +/// Уборка перевалочного каталога uploads/: удаляет файлы, которых не касались дольше порога. +/// После регистрации ассета файл уходит в originals/, а токен загрузки нигде не хранится — +/// значит, всё залежавшееся в uploads/ это след оборванной заливки или аварийного рестарта, +/// и подобрать его некому. +/// +public static class UploadsCleaner +{ + /// + /// Один проход. Порог считается от времени последней записи, а не создания: идущая многочасовая + /// заливка правит файл и под него не попадает. + /// + 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); + } + } +} diff --git a/backend/src/TeleWave.Infrastructure/Media/UploadsCleanupBackgroundService.cs b/backend/src/TeleWave.Infrastructure/Media/UploadsCleanupBackgroundService.cs new file mode 100644 index 0000000..af2691d --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Media/UploadsCleanupBackgroundService.cs @@ -0,0 +1,42 @@ +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace TeleWave.Infrastructure.Media; + +/// +/// Расписание уборки uploads/ (сама уборка — ): проход на старте +/// и раз в час. Удаление недописанного файла на месте +/// () закрывает штатные обрывы, этот сервис — +/// страховка от аварийного рестарта посреди заливки. +/// +public sealed class UploadsCleanupBackgroundService( + MediaPathResolver paths, + IOptions options, + ILogger logger +) : BackgroundService +{ + /// Как часто заглядывать в каталог. Порог измеряется часами — чаще незачем. + 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); + } +} diff --git a/backend/tests/TeleWave.Application.Tests/Media/UploadsCleanupTests.cs b/backend/tests/TeleWave.Application.Tests/Media/UploadsCleanupTests.cs new file mode 100644 index 0000000..87bb948 --- /dev/null +++ b/backend/tests/TeleWave.Application.Tests/Media/UploadsCleanupTests.cs @@ -0,0 +1,121 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using TeleWave.Infrastructure.Media; +using Xunit; + +namespace TeleWave.Application.Tests.Media; + +/// +/// Уборка перевалочного каталога: в uploads/ не должно оставаться файлов, на которые никто +/// не сослался — это гигабайты на диске, невидимые в админке. +/// +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(() => + 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); + } + + /// + /// Кладёт файл и «состаривает» его. Отметку перепроверяем: Windows дописывает время последней + /// записи при закрытии handle и может затереть выставленное сразу после записи. + /// + 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); + } + + /// Поток, обрывающийся на середине, — имитация отвалившегося клиента. + 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 ReadAsync( + Memory 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(); + } +} diff --git a/docs/media-storage-and-streaming.md b/docs/media-storage-and-streaming.md index 3698331..8f26caf 100644 --- a/docs/media-storage-and-streaming.md +++ b/docs/media-storage-and-streaming.md @@ -162,7 +162,7 @@ UTC; локальный пояс показывает фронтенд. Разд ``` /media ├── inbox/ # ручная укладка файлов (SFTP/rsync), подбирается сканером -├── uploads/ # незавершённые chunked-загрузки из админки +├── uploads/ # незавершённые загрузки из админки; регистрация уносит файл в originals/ ├── originals/{assetId}/ # исходник; удаляется после успешной нарезки └── assets/{assetId}/ ├── seg00000.ts … # сегменты строго по 2с @@ -227,6 +227,7 @@ Storage__SegmentSeconds=2 Storage__LiveWindowSegments=10 Storage__KeepOriginals=false Storage__MinFreeSpaceBytes=10737418240 +Storage__StaleUploadHours=24 Media__FfmpegPath=/usr/bin/ffmpeg Media__FfprobePath=/usr/bin/ffprobe Media__MaxUploadBytes=21474836480 @@ -264,6 +265,13 @@ Scheduler__TickMinutes=30 буферизации тела в память: chunked-куски пишутся в `uploads/`, `complete` делает атомарный `move` внутри той же ФС. +`uploads/` — перевалочный каталог: токен загрузки нигде не хранится, поэтому всё, что там осталось +после запроса, — мусор, на который никто не сослался. Убирается в два эшелона: недописанный файл +сносится на месте (обрыв заливки, отказ регистрации, сбой БД), а `UploadsCleanupBackgroundService` +раз в час подметает залежавшееся дольше `Storage__StaleUploadHours` (0 — уборку не делать) — это +страховка от аварийного рестарта посреди заливки. Порог считается от времени последней записи, +поэтому идущая многочасовая загрузка под него не попадает. + ## Открытые эксплуатационные вопросы - Удаление ассета/шоу запрещать, пока на них ссылается будущее расписание или пул канала. diff --git a/frontend/src/features/admin/shows/ShowDetail.tsx b/frontend/src/features/admin/shows/ShowDetail.tsx index 1c3a286..6a8420b 100644 --- a/frontend/src/features/admin/shows/ShowDetail.tsx +++ b/frontend/src/features/admin/shows/ShowDetail.tsx @@ -170,7 +170,9 @@ export function ShowDetail({ showId }: Readonly<{ showId: string }>) { audienceMutation.mutate(v === AUDIENCE_UNSET ? null : (v as ShowAudience)) } > - + {/* Ширина под самую длинную расшифровку рейтинга («NC-17 — только взрослым»): + у́же — и она переносится на вторую строку, ломая высоту строки заголовка. */} + diff --git a/frontend/src/features/admin/shows/ShowsPanel.tsx b/frontend/src/features/admin/shows/ShowsPanel.tsx index 6180460..4767cc5 100644 --- a/frontend/src/features/admin/shows/ShowsPanel.tsx +++ b/frontend/src/features/admin/shows/ShowsPanel.tsx @@ -118,7 +118,8 @@ export function ShowsPanel() { value={audience ?? AUDIENCE_UNSET} onValueChange={(v) => setAudience(v === AUDIENCE_UNSET ? null : (v as ShowAudience))} > - + {/* Та же ширина, что у селекта на карточке шоу: расшифровки рейтингов длинные. */} +