fix: harden recording recovery and dashboard metrics
This commit is contained in:
@@ -272,7 +272,7 @@ public sealed class FfmpegFailureClassificationTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecoveryContext_LeavesCurlAfterExitWithoutResettingOneShotBudget()
|
||||
public void RefreshedFlvRecovery_ReEnablesCurlAfterOverlongHeaders()
|
||||
{
|
||||
var curlContext = FfmpegService.InitialRecoveryContext with
|
||||
{
|
||||
@@ -280,18 +280,65 @@ public sealed class FfmpegFailureClassificationTests
|
||||
HasTriedCurlFallback = true
|
||||
};
|
||||
|
||||
var nextContext = FfmpegService.AdvanceRecoveryContext(
|
||||
curlContext,
|
||||
curlContext.InputOptionProfile,
|
||||
var nextContext = FfmpegService.PrepareRefreshedStreamRecoveryContext(
|
||||
FfmpegService.AdvanceRecoveryContext(
|
||||
curlContext,
|
||||
curlContext.InputOptionProfile,
|
||||
"flv",
|
||||
"flv",
|
||||
refreshedStream: true),
|
||||
"flv",
|
||||
"flv",
|
||||
refreshedStream: true);
|
||||
hasOverlongHeadersFailure: true);
|
||||
|
||||
Assert.False(nextContext.UseCurlFallback);
|
||||
Assert.True(nextContext.UseCurlFallback);
|
||||
Assert.True(nextContext.HasTriedCurlFallback);
|
||||
Assert.True(nextContext.HasRetriedWithRefreshedStream);
|
||||
Assert.False(FfmpegService.ShouldFallbackToCurl("flv", true, nextContext.HasTriedCurlFallback));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RefreshedNonFlvRecovery_DisablesCurl()
|
||||
{
|
||||
var nextContext = FfmpegService.PrepareRefreshedStreamRecoveryContext(
|
||||
FfmpegService.InitialRecoveryContext with
|
||||
{
|
||||
UseCurlFallback = true,
|
||||
HasTriedCurlFallback = true
|
||||
},
|
||||
"hls",
|
||||
hasOverlongHeadersFailure: true);
|
||||
|
||||
Assert.False(nextContext.UseCurlFallback);
|
||||
Assert.True(nextContext.HasTriedCurlFallback);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RefreshedFlvRecovery_PreservesCurlRequirementAcrossLaterProcesses()
|
||||
{
|
||||
var priorContext = FfmpegService.InitialRecoveryContext with
|
||||
{
|
||||
UseCurlFallback = false,
|
||||
HasTriedCurlFallback = true
|
||||
};
|
||||
|
||||
var nextContext = FfmpegService.PrepareRefreshedStreamRecoveryContext(
|
||||
priorContext,
|
||||
"flv",
|
||||
hasOverlongHeadersFailure: priorContext.HasTriedCurlFallback);
|
||||
|
||||
Assert.True(nextContext.UseCurlFallback);
|
||||
Assert.True(nextContext.HasTriedCurlFallback);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false, true, false)]
|
||||
[InlineData(false, false, true)]
|
||||
[InlineData(true, true, true)]
|
||||
public void FfmpegLinePersistence_SuppressesRecoverableStartupHandshakeNoise(
|
||||
bool hasOpenedFirstSegment,
|
||||
bool isStreamHandshakeFailure,
|
||||
bool expected) =>
|
||||
Assert.Equal(expected, FfmpegService.ShouldPersistFfmpegLine(hasOpenedFirstSegment, isStreamHandshakeFailure));
|
||||
[Fact]
|
||||
public void RecoveryContext_PreservesBudgetAcrossHlsFallbackAndReachesTimestampTranscode()
|
||||
{
|
||||
|
||||
@@ -615,6 +615,62 @@ public sealed class OpenListUploadTests
|
||||
Assert.Equal(1, await repository.CountFailedArtifactAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PendingUploadMetrics_IncludeValidVisibleArtifactWithActualFileSize()
|
||||
{
|
||||
await using var fixture = await QueueFixture.CreateAsync();
|
||||
var result = await fixture.Context.RecordResults.AsNoTracking().SingleAsync();
|
||||
var repository = new RecordResultRepository(fixture.Context);
|
||||
|
||||
var metrics = await repository.GetPendingUploadMetricsAsync();
|
||||
|
||||
Assert.Equal(1, metrics.Count);
|
||||
Assert.Equal(new FileInfo(result.FilePath).Length, metrics.TotalBytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PendingUploadMetrics_ExcludeHiddenMergedSource()
|
||||
{
|
||||
await using var fixture = await QueueFixture.CreateAsync();
|
||||
var task = await fixture.Context.RecordTasks.SingleAsync();
|
||||
task.MarkMergedSource(Guid.NewGuid(), DateTimeOffset.UtcNow);
|
||||
await fixture.Context.SaveChangesAsync();
|
||||
var repository = new RecordResultRepository(fixture.Context);
|
||||
|
||||
var metrics = await repository.GetPendingUploadMetricsAsync();
|
||||
|
||||
Assert.Equal(0, metrics.Count);
|
||||
Assert.Equal(0, metrics.TotalBytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PendingUploadMetrics_ExcludeMissingLocalFile()
|
||||
{
|
||||
await using var fixture = await QueueFixture.CreateAsync();
|
||||
var result = await fixture.Context.RecordResults.AsNoTracking().SingleAsync();
|
||||
File.Delete(result.FilePath);
|
||||
var repository = new RecordResultRepository(fixture.Context);
|
||||
|
||||
var metrics = await repository.GetPendingUploadMetricsAsync();
|
||||
|
||||
Assert.Equal(0, metrics.Count);
|
||||
Assert.Equal(0, metrics.TotalBytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PendingUploadMetrics_ExcludeEmptyLocalFile()
|
||||
{
|
||||
await using var fixture = await QueueFixture.CreateAsync();
|
||||
var result = await fixture.Context.RecordResults.AsNoTracking().SingleAsync();
|
||||
File.WriteAllBytes(result.FilePath, []);
|
||||
var repository = new RecordResultRepository(fixture.Context);
|
||||
|
||||
var metrics = await repository.GetPendingUploadMetricsAsync();
|
||||
|
||||
Assert.Equal(0, metrics.Count);
|
||||
Assert.Equal(0, metrics.TotalBytes);
|
||||
}
|
||||
|
||||
private static OpenListClient CreateClient(HttpMessageHandler handler) =>
|
||||
new(new StubHttpClientFactory(handler));
|
||||
|
||||
|
||||
@@ -20,8 +20,11 @@ public sealed class RecordingContinuityTests
|
||||
[InlineData(3, 30)]
|
||||
[InlineData(4, 60)]
|
||||
[InlineData(5, 120)]
|
||||
[InlineData(50, 120)]
|
||||
public void RuntimeRecoveryBackoff_IsBoundedAtTwoMinutes(int attempt, int seconds) =>
|
||||
[InlineData(6, 300)]
|
||||
[InlineData(7, 600)]
|
||||
[InlineData(8, 900)]
|
||||
[InlineData(50, 900)]
|
||||
public void RuntimeRecoveryBackoff_IsBoundedAtFifteenMinutes(int attempt, int seconds) =>
|
||||
Assert.Equal(TimeSpan.FromSeconds(seconds), FfmpegService.GetRuntimeRecoveryDelay(attempt));
|
||||
|
||||
[Theory]
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
using LiveRecorder.Application.Abstractions.Logging;
|
||||
using LiveRecorder.Application.Models.Logs;
|
||||
using LiveRecorder.Domain.Entities;
|
||||
using LiveRecorder.Domain.Enums;
|
||||
using LiveRecorder.Infrastructure.Persistence;
|
||||
using LiveRecorder.Infrastructure.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Storage;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace LiveRecorder.Tests;
|
||||
|
||||
public sealed class StalePendingRecoveryTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task StalePendingSessionWithoutMedia_IsFailedAndRetainedForAudit()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
var databaseRoot = new InMemoryDatabaseRoot();
|
||||
var databaseName = $"stale-pending-{Guid.NewGuid():N}";
|
||||
services.AddDbContext<LiveRecorderDbContext>(options =>
|
||||
options.UseInMemoryDatabase(databaseName, databaseRoot));
|
||||
var logService = new CapturingSystemLogService();
|
||||
services.AddSingleton<ISystemLogService>(logService);
|
||||
await using var provider = services.BuildServiceProvider();
|
||||
|
||||
var createdAt = DateTimeOffset.UtcNow.AddMinutes(-11);
|
||||
Guid sessionId;
|
||||
Guid taskId;
|
||||
await using (var scope = provider.CreateAsyncScope())
|
||||
{
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<LiveRecorderDbContext>();
|
||||
var liveRoom = new LiveRoom(
|
||||
LivePlatformType.Douyin,
|
||||
"https://live.example/room",
|
||||
"room-1",
|
||||
"https://live.example/room",
|
||||
createdAt);
|
||||
var session = new RecordSession(
|
||||
liveRoom.Id,
|
||||
"origin",
|
||||
RecordOutputFormat.Mp4,
|
||||
RecordSaveMode.Segmented,
|
||||
createdAt);
|
||||
var task = new RecordTask(
|
||||
liveRoom.Id,
|
||||
session.Id,
|
||||
1,
|
||||
"origin",
|
||||
RecordOutputFormat.Mp4,
|
||||
createdAt);
|
||||
sessionId = session.Id;
|
||||
taskId = task.Id;
|
||||
dbContext.AddRange(liveRoom, session, task);
|
||||
await dbContext.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var service = new FfmpegService(
|
||||
provider.GetRequiredService<IServiceScopeFactory>(),
|
||||
null!,
|
||||
null!,
|
||||
NullLogger<FfmpegService>.Instance);
|
||||
|
||||
await using (var preconditionScope = provider.CreateAsyncScope())
|
||||
{
|
||||
var preconditionContext = preconditionScope.ServiceProvider.GetRequiredService<LiveRecorderDbContext>();
|
||||
var persisted = await preconditionContext.RecordSessions.SingleAsync();
|
||||
Assert.Equal(RecordSessionStatus.Pending, persisted.Status);
|
||||
Assert.True(
|
||||
persisted.UpdatedAt <= DateTimeOffset.UtcNow.AddMinutes(-10),
|
||||
$"updatedAt={persisted.UpdatedAt:O}");
|
||||
}
|
||||
|
||||
var recovered = await service.RecoverStalePendingSessionsAsync();
|
||||
|
||||
Assert.Equal(1, recovered);
|
||||
await using var verificationScope = provider.CreateAsyncScope();
|
||||
var verificationContext = verificationScope.ServiceProvider.GetRequiredService<LiveRecorderDbContext>();
|
||||
var persistedSession = await verificationContext.RecordSessions.SingleAsync(item => item.Id == sessionId);
|
||||
var persistedTask = await verificationContext.RecordTasks
|
||||
.Include(item => item.Result)
|
||||
.SingleAsync(item => item.Id == taskId);
|
||||
Assert.Equal(RecordSessionStatus.Failed, persistedSession.Status);
|
||||
Assert.Equal(RecordTaskStatus.Failed, persistedTask.Status);
|
||||
Assert.Contains("interrupted before stream initialization", persistedSession.ErrorMessage);
|
||||
Assert.Null(persistedTask.Result);
|
||||
Assert.Single(logService.Entries);
|
||||
Assert.Equal(SystemLogLevel.Warning, logService.Entries[0].Level);
|
||||
}
|
||||
|
||||
private sealed class CapturingSystemLogService : ISystemLogService
|
||||
{
|
||||
public List<(SystemLogLevel Level, string Message)> Entries { get; } = [];
|
||||
|
||||
public Task WriteAsync(
|
||||
SystemLogLevel level,
|
||||
string category,
|
||||
string message,
|
||||
string? detail = null,
|
||||
Guid? liveRoomId = null,
|
||||
Guid? recordSessionId = null,
|
||||
Guid? recordTaskId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Entries.Add((level, message));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<SystemLogDto>> ListAsync(
|
||||
Guid? liveRoomId = null,
|
||||
Guid? recordSessionId = null,
|
||||
Guid? recordTaskId = null,
|
||||
SystemLogLevel? level = null,
|
||||
string? content = null,
|
||||
int take = 200,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult<IReadOnlyList<SystemLogDto>>([]);
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,26 @@ namespace LiveRecorder.Tests;
|
||||
|
||||
public sealed class StorageGuardServiceTests
|
||||
{
|
||||
private readonly StorageGuardService _service = new(NullLogger<StorageGuardService>.Instance);
|
||||
private readonly StorageGuardService _service = new(
|
||||
NullLogger<StorageGuardService>.Instance,
|
||||
new DriveInfoStorageCapacityProvider());
|
||||
|
||||
[Fact]
|
||||
public void CapacityProvider_SelectsLongestContainingMountRoot()
|
||||
{
|
||||
var selected = DriveInfoStorageCapacityProvider.SelectBestVolume(
|
||||
"/vol00/live-recorder/Record",
|
||||
[
|
||||
new StorageVolumeCapacity("/", 100, 10),
|
||||
new StorageVolumeCapacity("/vol00", 200, 20),
|
||||
new StorageVolumeCapacity("/vol00/live-recorder", 300, 30),
|
||||
new StorageVolumeCapacity("/vol0", 400, 40)
|
||||
]);
|
||||
|
||||
Assert.NotNull(selected);
|
||||
Assert.Equal("/vol00/live-recorder", selected.VolumeRoot);
|
||||
Assert.Equal(300, selected.TotalBytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DisabledProtection_StillReportsActualCapacityWithoutBlockingRecording()
|
||||
|
||||
Reference in New Issue
Block a user