fix: recover merged uploads and rotate logs
This commit is contained in:
@@ -15,7 +15,10 @@ CA_BUNDLE="$RUNTIME_ROOT/etc/ssl/certs/ca-certificates.crt"
|
|||||||
RUN_ROOT="$DATA_ROOT/run"
|
RUN_ROOT="$DATA_ROOT/run"
|
||||||
LOG_ROOT="$DATA_ROOT/log"
|
LOG_ROOT="$DATA_ROOT/log"
|
||||||
APP_PID_FILE="$RUN_ROOT/liverecorder.pid"
|
APP_PID_FILE="$RUN_ROOT/liverecorder.pid"
|
||||||
|
LOG_MONITOR_PID_FILE="$RUN_ROOT/liverecorder-log-monitor.pid"
|
||||||
APP_LOG="$LOG_ROOT/liverecorder.log"
|
APP_LOG="$LOG_ROOT/liverecorder.log"
|
||||||
|
APP_LOG_MAX_BYTES=52428800
|
||||||
|
APP_LOG_RETAINED_FILES=3
|
||||||
ADMIN_PASSWORD_FILE="$DATA_ROOT/admin-password.seed"
|
ADMIN_PASSWORD_FILE="$DATA_ROOT/admin-password.seed"
|
||||||
POSTGRES_ENROLLMENT_TOKEN_FILE="$DATA_ROOT/postgres-enrollment-token.seed"
|
POSTGRES_ENROLLMENT_TOKEN_FILE="$DATA_ROOT/postgres-enrollment-token.seed"
|
||||||
POSTGRES_CREDENTIALS_FILE="$DATA_ROOT/postgres-client.conf"
|
POSTGRES_CREDENTIALS_FILE="$DATA_ROOT/postgres-client.conf"
|
||||||
@@ -36,6 +39,49 @@ log_message() {
|
|||||||
printf '%s - %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$1" >>"$APP_LOG"
|
printf '%s - %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$1" >>"$APP_LOG"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
rotate_app_log() {
|
||||||
|
[ -f "$APP_LOG" ] || return 0
|
||||||
|
size=$(wc -c <"$APP_LOG" 2>/dev/null | tr -d '[:space:]')
|
||||||
|
case "$size" in ''|*[!0-9]*) return 0 ;; esac
|
||||||
|
[ "$size" -ge "$APP_LOG_MAX_BYTES" ] || return 0
|
||||||
|
|
||||||
|
index=$APP_LOG_RETAINED_FILES
|
||||||
|
rm -f "$APP_LOG.$index"
|
||||||
|
while [ "$index" -gt 1 ]; do
|
||||||
|
previous=$((index - 1))
|
||||||
|
if [ -f "$APP_LOG.$previous" ]; then
|
||||||
|
mv "$APP_LOG.$previous" "$APP_LOG.$index"
|
||||||
|
fi
|
||||||
|
index=$previous
|
||||||
|
done
|
||||||
|
|
||||||
|
tail -c "$APP_LOG_MAX_BYTES" "$APP_LOG" >"$APP_LOG.1.tmp" 2>/dev/null || return 0
|
||||||
|
mv "$APP_LOG.1.tmp" "$APP_LOG.1"
|
||||||
|
: >"$APP_LOG"
|
||||||
|
}
|
||||||
|
|
||||||
|
start_log_monitor() {
|
||||||
|
rm -f "$LOG_MONITOR_PID_FILE"
|
||||||
|
(
|
||||||
|
while app_pid >/dev/null 2>&1; do
|
||||||
|
rotate_app_log
|
||||||
|
sleep 60
|
||||||
|
done
|
||||||
|
) &
|
||||||
|
printf '%s\n' "$!" >"$LOG_MONITOR_PID_FILE"
|
||||||
|
}
|
||||||
|
|
||||||
|
stop_log_monitor() {
|
||||||
|
if [ -f "$LOG_MONITOR_PID_FILE" ]; then
|
||||||
|
monitor_pid=$(sed -n '1p' "$LOG_MONITOR_PID_FILE" | tr -d '[:space:]')
|
||||||
|
case "$monitor_pid" in
|
||||||
|
''|*[!0-9]*) ;;
|
||||||
|
*) kill "$monitor_pid" 2>/dev/null || true ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
rm -f "$LOG_MONITOR_PID_FILE"
|
||||||
|
}
|
||||||
|
|
||||||
app_pid() {
|
app_pid() {
|
||||||
if [ -f "$APP_PID_FILE" ]; then
|
if [ -f "$APP_PID_FILE" ]; then
|
||||||
pid=$(sed -n '1p' "$APP_PID_FILE" | tr -d '[:space:]')
|
pid=$(sed -n '1p' "$APP_PID_FILE" | tr -d '[:space:]')
|
||||||
@@ -199,10 +245,13 @@ start_app() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
rm -f "$APP_PID_FILE"
|
rm -f "$APP_PID_FILE"
|
||||||
|
stop_log_monitor
|
||||||
|
rotate_app_log
|
||||||
select_database_connection || return 1
|
select_database_connection || return 1
|
||||||
launch_attempt=1
|
launch_attempt=1
|
||||||
launch_app_process
|
launch_app_process
|
||||||
pid=$APP_PROCESS_PID
|
pid=$APP_PROCESS_PID
|
||||||
|
start_log_monitor
|
||||||
|
|
||||||
attempt=0
|
attempt=0
|
||||||
while [ "$attempt" -lt 90 ]; do
|
while [ "$attempt" -lt 90 ]; do
|
||||||
@@ -231,6 +280,7 @@ start_app() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
stop_app() {
|
stop_app() {
|
||||||
|
stop_log_monitor
|
||||||
if pid=$(app_pid); then
|
if pid=$(app_pid); then
|
||||||
kill "$pid" 2>/dev/null || true
|
kill "$pid" 2>/dev/null || true
|
||||||
attempt=0
|
attempt=0
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ namespace LiveRecorder.Infrastructure.Services;
|
|||||||
public sealed class CompletionDispatchService
|
public sealed class CompletionDispatchService
|
||||||
{
|
{
|
||||||
private static readonly TimeSpan RetryDelay = TimeSpan.FromMinutes(1);
|
private static readonly TimeSpan RetryDelay = TimeSpan.FromMinutes(1);
|
||||||
|
private static readonly SemaphoreSlim[] SessionDispatchLocks = Enumerable.Range(0, 64)
|
||||||
|
.Select(static _ => new SemaphoreSlim(1, 1))
|
||||||
|
.ToArray();
|
||||||
private readonly LiveRecorderDbContext _dbContext;
|
private readonly LiveRecorderDbContext _dbContext;
|
||||||
private readonly IEventScriptService _eventScriptService;
|
private readonly IEventScriptService _eventScriptService;
|
||||||
private readonly RecordUploadService _recordUploadService;
|
private readonly RecordUploadService _recordUploadService;
|
||||||
@@ -70,8 +73,27 @@ public sealed class CompletionDispatchService
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var dispatchLock = GetSessionDispatchLock(task.RecordSessionId);
|
||||||
|
await dispatchLock.WaitAsync(cancellationToken);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
await _dbContext.Entry(dispatch).ReloadAsync(cancellationToken);
|
||||||
|
if (dispatch.CompletedAt.HasValue)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _dbContext.Entry(task).ReloadAsync(cancellationToken);
|
||||||
|
if (task.Result is not null)
|
||||||
|
{
|
||||||
|
await _dbContext.Entry(task.Result).ReloadAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (task.RecordSession is not null)
|
||||||
|
{
|
||||||
|
await _dbContext.Entry(task.RecordSession).ReloadAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
var consolidation = await _shortFragmentConsolidationService.PrepareDispatchAsync(task.Id, cancellationToken);
|
var consolidation = await _shortFragmentConsolidationService.PrepareDispatchAsync(task.Id, cancellationToken);
|
||||||
if (consolidation.SkipDispatch)
|
if (consolidation.SkipDispatch)
|
||||||
{
|
{
|
||||||
@@ -90,15 +112,17 @@ public sealed class CompletionDispatchService
|
|||||||
|
|
||||||
await _dbContext.Entry(task).ReloadAsync(cancellationToken);
|
await _dbContext.Entry(task).ReloadAsync(cancellationToken);
|
||||||
await _dbContext.Entry(task).Reference(item => item.Result).LoadAsync(cancellationToken);
|
await _dbContext.Entry(task).Reference(item => item.Result).LoadAsync(cancellationToken);
|
||||||
|
var recordSession = task.RecordSession ?? throw new InvalidOperationException();
|
||||||
|
var recordResult = task.Result ?? throw new InvalidOperationException();
|
||||||
|
|
||||||
if (!dispatch.ScriptDispatched)
|
if (!dispatch.ScriptDispatched)
|
||||||
{
|
{
|
||||||
var scriptResult = await _eventScriptService.RunSegmentCompletedAsync(
|
var scriptResult = await _eventScriptService.RunSegmentCompletedAsync(
|
||||||
task.LiveRoom,
|
task.LiveRoom,
|
||||||
task.RecordSession,
|
recordSession,
|
||||||
task,
|
task,
|
||||||
task.Result,
|
recordResult,
|
||||||
task.Result.FilePath,
|
recordResult.FilePath,
|
||||||
task.EndedAt ?? DateTimeOffset.UtcNow,
|
task.EndedAt ?? DateTimeOffset.UtcNow,
|
||||||
eventId: dispatch.Id,
|
eventId: dispatch.Id,
|
||||||
cancellationToken: cancellationToken);
|
cancellationToken: cancellationToken);
|
||||||
@@ -125,6 +149,16 @@ public sealed class CompletionDispatchService
|
|||||||
dispatch.ScheduleRetry(ex.Message, DateTimeOffset.UtcNow.Add(RetryDelay), DateTimeOffset.UtcNow);
|
dispatch.ScheduleRetry(ex.Message, DateTimeOffset.UtcNow.Add(RetryDelay), DateTimeOffset.UtcNow);
|
||||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
dispatchLock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SemaphoreSlim GetSessionDispatchLock(Guid recordSessionId)
|
||||||
|
{
|
||||||
|
var hash = recordSessionId.GetHashCode() & int.MaxValue;
|
||||||
|
return SessionDispatchLocks[hash % SessionDispatchLocks.Length];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -114,7 +114,6 @@ public sealed class OpenListUploadQueueService
|
|||||||
!item.IsHiddenArtifactSource &&
|
!item.IsHiddenArtifactSource &&
|
||||||
item.Result != null &&
|
item.Result != null &&
|
||||||
item.Result.UploadStatus == RecordArtifactUploadStatus.NotUploaded &&
|
item.Result.UploadStatus == RecordArtifactUploadStatus.NotUploaded &&
|
||||||
item.UploadJob == null &&
|
|
||||||
item.UpdatedAt <= completedBefore &&
|
item.UpdatedAt <= completedBefore &&
|
||||||
!string.IsNullOrWhiteSpace(item.Result.FilePath))
|
!string.IsNullOrWhiteSpace(item.Result.FilePath))
|
||||||
.OrderBy(static item => item.UpdatedAt)
|
.OrderBy(static item => item.UpdatedAt)
|
||||||
@@ -122,6 +121,28 @@ public sealed class OpenListUploadQueueService
|
|||||||
.Take(Math.Clamp(take, 1, MaxAutomaticRecoveryBatchSize))
|
.Take(Math.Clamp(take, 1, MaxAutomaticRecoveryBatchSize))
|
||||||
.ToArrayAsync(cancellationToken);
|
.ToArrayAsync(cancellationToken);
|
||||||
|
|
||||||
|
var changedArtifactTaskIds = await _dbContext.RecordTasks
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(item =>
|
||||||
|
(item.Status == RecordTaskStatus.Completed || item.Status == RecordTaskStatus.Stopped) &&
|
||||||
|
!item.IsHiddenArtifactSource &&
|
||||||
|
item.Result != null &&
|
||||||
|
item.UploadJob != null &&
|
||||||
|
item.Result.UploadStatus == RecordArtifactUploadStatus.Succeeded &&
|
||||||
|
item.UploadJob.Status == RecordArtifactUploadStatus.Succeeded &&
|
||||||
|
item.Result.FileSizeBytes != item.UploadJob.VideoSizeBytes &&
|
||||||
|
!string.IsNullOrWhiteSpace(item.Result.FilePath))
|
||||||
|
.OrderBy(static item => item.UpdatedAt)
|
||||||
|
.Select(static item => item.Id)
|
||||||
|
.Take(Math.Clamp(take, 1, MaxAutomaticRecoveryBatchSize))
|
||||||
|
.ToArrayAsync(cancellationToken);
|
||||||
|
|
||||||
|
taskIds = taskIds
|
||||||
|
.Concat(changedArtifactTaskIds)
|
||||||
|
.Distinct()
|
||||||
|
.Take(Math.Clamp(take, 1, MaxAutomaticRecoveryBatchSize))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
var recovered = 0;
|
var recovered = 0;
|
||||||
foreach (var taskId in taskIds)
|
foreach (var taskId in taskIds)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ public sealed class ShortFragmentConsolidationService
|
|||||||
|
|
||||||
var tasks = await _dbContext.RecordTasks
|
var tasks = await _dbContext.RecordTasks
|
||||||
.Include(item => item.Result)
|
.Include(item => item.Result)
|
||||||
|
.Include(item => item.UploadJob)
|
||||||
.Where(item => item.RecordSessionId == task.RecordSessionId && !item.IsHiddenArtifactSource)
|
.Where(item => item.RecordSessionId == task.RecordSessionId && !item.IsHiddenArtifactSource)
|
||||||
.OrderBy(item => item.SegmentIndex)
|
.OrderBy(item => item.SegmentIndex)
|
||||||
.ThenBy(item => item.CreatedAt)
|
.ThenBy(item => item.CreatedAt)
|
||||||
@@ -159,11 +160,22 @@ public sealed class ShortFragmentConsolidationService
|
|||||||
|
|
||||||
private static List<RecordTask> ResolveMergeGroup(IReadOnlyList<RecordTask> tasks, int index)
|
private static List<RecordTask> ResolveMergeGroup(IReadOnlyList<RecordTask> tasks, int index)
|
||||||
{
|
{
|
||||||
var durations = tasks.Select(item => IsReadableTerminal(item) ? item.Result!.DurationSeconds : null).ToArray();
|
var durations = tasks
|
||||||
|
.Select(item => IsReadableTerminal(item) && IsMergeEligible(item.Result!.UploadStatus, item.UploadJob is not null)
|
||||||
|
? item.Result.DurationSeconds
|
||||||
|
: null)
|
||||||
|
.ToArray();
|
||||||
var (start, end) = ResolveMergeWindow(durations, index);
|
var (start, end) = ResolveMergeWindow(durations, index);
|
||||||
return tasks.Skip(start).Take(end - start + 1).Where(IsReadableTerminal).ToList();
|
return tasks
|
||||||
|
.Skip(start)
|
||||||
|
.Take(end - start + 1)
|
||||||
|
.Where(item => IsReadableTerminal(item) && IsMergeEligible(item.Result!.UploadStatus, item.UploadJob is not null))
|
||||||
|
.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal static bool IsMergeEligible(RecordArtifactUploadStatus uploadStatus, bool hasUploadJob) =>
|
||||||
|
uploadStatus == RecordArtifactUploadStatus.NotUploaded && !hasUploadJob;
|
||||||
|
|
||||||
internal static (int Start, int End) ResolveMergeWindow(IReadOnlyList<double?> durations, int index)
|
internal static (int Start, int End) ResolveMergeWindow(IReadOnlyList<double?> durations, int index)
|
||||||
{
|
{
|
||||||
if (durations.Count == 0 || index < 0 || index >= durations.Count) return (-1, -1);
|
if (durations.Count == 0 || index < 0 || index >= durations.Count) return (-1, -1);
|
||||||
|
|||||||
@@ -553,6 +553,40 @@ public sealed class OpenListUploadTests
|
|||||||
Assert.Equal(1, await fixture.Context.RecordUploadJobs.CountAsync());
|
Assert.Equal(1, await fixture.Context.RecordUploadJobs.CountAsync());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AutomaticRecovery_RequeuesSucceededJobWhenMergedArtifactDrifts()
|
||||||
|
{
|
||||||
|
await using var fixture = await QueueFixture.CreateAsync();
|
||||||
|
await fixture.Queue.EnqueueAsync(fixture.RecordTaskId);
|
||||||
|
var originalJob = await fixture.Context.RecordUploadJobs.AsNoTracking().SingleAsync();
|
||||||
|
fixture.OpenList.Objects[originalJob.TargetVideoPath] = new OpenListObjectInfo(
|
||||||
|
"segment.mp4",
|
||||||
|
originalJob.VideoSizeBytes,
|
||||||
|
false,
|
||||||
|
new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["sha256"] = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes("video-content"))).ToLowerInvariant()
|
||||||
|
});
|
||||||
|
Assert.True(await fixture.Queue.ProcessNextAsync());
|
||||||
|
|
||||||
|
var result = await fixture.Context.RecordResults.SingleAsync();
|
||||||
|
var mergedPath = Path.Combine(Path.GetDirectoryName(result.FilePath)!, "segment-merged-recovery.mp4");
|
||||||
|
await File.WriteAllBytesAsync(mergedPath, Encoding.UTF8.GetBytes("merged-video-content"));
|
||||||
|
result.Update(mergedPath, new FileInfo(mergedPath).Length, 61, null, 0, RecordTaskStatus.Completed, null);
|
||||||
|
await fixture.Context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var recovered = await fixture.Queue.RecoverPendingAutomaticUploadsAsync();
|
||||||
|
|
||||||
|
Assert.Equal(1, recovered);
|
||||||
|
fixture.Context.ChangeTracker.Clear();
|
||||||
|
var refreshedJob = await fixture.Context.RecordUploadJobs.SingleAsync();
|
||||||
|
var refreshedResult = await fixture.Context.RecordResults.SingleAsync();
|
||||||
|
Assert.Equal(RecordArtifactUploadStatus.Queued, refreshedJob.Status);
|
||||||
|
Assert.Equal(RecordArtifactUploadStatus.Queued, refreshedResult.UploadStatus);
|
||||||
|
Assert.EndsWith("segment-merged-recovery.mp4", refreshedJob.TargetVideoPath, StringComparison.Ordinal);
|
||||||
|
Assert.Equal(originalJob.TargetVideoPath, refreshedJob.PendingCleanupVideoPath);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task CompletionOutbox_IsCreatedWhenRecordResultWasWrittenOutsideEfTracking()
|
public async Task CompletionOutbox_IsCreatedWhenRecordResultWasWrittenOutsideEfTracking()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -94,6 +94,18 @@ public sealed class RecordingContinuityTests
|
|||||||
Assert.Equal((0, 2), window);
|
Assert.Equal((0, 2), window);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(RecordArtifactUploadStatus.NotUploaded, false, true)]
|
||||||
|
[InlineData(RecordArtifactUploadStatus.NotUploaded, true, false)]
|
||||||
|
[InlineData(RecordArtifactUploadStatus.Queued, true, false)]
|
||||||
|
[InlineData(RecordArtifactUploadStatus.Uploading, true, false)]
|
||||||
|
[InlineData(RecordArtifactUploadStatus.Succeeded, true, false)]
|
||||||
|
public void ShortFragmentMerge_RejectsArtifactsThatEnteredUpload(
|
||||||
|
RecordArtifactUploadStatus uploadStatus,
|
||||||
|
bool hasUploadJob,
|
||||||
|
bool expected) =>
|
||||||
|
Assert.Equal(expected, ShortFragmentConsolidationService.IsMergeEligible(uploadStatus, hasUploadJob));
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void DanmakuMerge_ShiftsRelativeOffset_ButPreservesAbsoluteTimestamp()
|
public void DanmakuMerge_ShiftsRelativeOffset_ButPreservesAbsoluteTimestamp()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user