fix(media): 清理旧 rendition 并确保失败状态落库
This commit is contained in:
1 parent
5d1a157085
commit
91b621fe75
9 files changed
+226
-35
No files matched your search
+47
@@ -199,6 +199,53 @@ class TranscodeClaimAndStorageTest {
|
||||
verify(transcodeTaskMapper).updateById(task);
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_delete_old_renditions_by_media_or_episode_before_insert() {
|
||||
TranscodeTask task = task(6L);
|
||||
task.setMediaAssetId(99L);
|
||||
task.setEpisodeId(31L);
|
||||
given(transcodeTaskMapper.selectById(6L)).willReturn(task);
|
||||
MediaRendition rendition = new MediaRendition();
|
||||
rendition.setQuality("360p");
|
||||
rendition.setFormat("HLS");
|
||||
|
||||
transcodeService.succeed(6L, java.util.List.of(rendition));
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
ArgumentCaptor<Wrapper<MediaRendition>> captor = ArgumentCaptor.forClass(Wrapper.class);
|
||||
verify(mediaRenditionMapper).delete(captor.capture());
|
||||
assertThat(captor.getValue().getSqlSegment())
|
||||
.containsIgnoringCase("media_asset_id")
|
||||
.containsIgnoringCase("episode_id")
|
||||
.containsIgnoringCase("OR");
|
||||
assertThat(captor.getValue().getSqlSegment())
|
||||
.doesNotContainIgnoringCase("AND (");
|
||||
verify(mediaRenditionMapper).insert(rendition);
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_replace_existing_thumbnail_by_episode_id() {
|
||||
TranscodeTask task = task(7L);
|
||||
task.setDramaId(7L);
|
||||
task.setEpisodeId(31L);
|
||||
MediaThumbnail existing = new MediaThumbnail();
|
||||
existing.setId(70L);
|
||||
existing.setEpisodeId(31L);
|
||||
given(mediaThumbnailMapper.selectOne(any())).willReturn(existing);
|
||||
MediaThumbnail thumbnail = new MediaThumbnail();
|
||||
thumbnail.setEpisodeId(31L);
|
||||
thumbnail.setStatus(MediaThumbnail.STATUS_READY);
|
||||
ThumbnailRecordService recordService = new ThumbnailRecordService(mediaThumbnailMapper);
|
||||
|
||||
recordService.upsert(task, thumbnail);
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
ArgumentCaptor<Wrapper<MediaThumbnail>> captor = ArgumentCaptor.forClass(Wrapper.class);
|
||||
verify(mediaThumbnailMapper).delete(captor.capture());
|
||||
assertThat(captor.getValue().getSqlSegment()).containsIgnoringCase("episode_id");
|
||||
verify(mediaThumbnailMapper).insert(thumbnail);
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_reject_object_key_that_escapes_the_storage_root() {
|
||||
assertThatThrownBy(() -> storageService.fileFor("../../etc/passwd"))
|
||||
|
||||
+30
@@ -3,6 +3,7 @@
|
||||
package com.fruitshorts.worker;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import com.fruitshorts.media.dal.entity.MediaRendition;
|
||||
import com.fruitshorts.media.dal.entity.MediaThumbnail;
|
||||
@@ -17,6 +18,9 @@ import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
@@ -142,6 +146,32 @@ class TranscodeServiceTriggerTest {
|
||||
verify(taskMapper).update(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_persist_failed_status_and_tail_when_fail_final_is_called() {
|
||||
given(taskMapper.update(any(), any())).willReturn(1);
|
||||
String reason = "ffmpeg output head: " + "x".repeat(1000)
|
||||
+ " UNIQUE_TAIL_DUPLICATE_KEY";
|
||||
|
||||
transcodeService.failFinal(61L, reason);
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
ArgumentCaptor<com.baomidou.mybatisplus.core.conditions.Wrapper<TranscodeTask>>
|
||||
captor = ArgumentCaptor.forClass(
|
||||
com.baomidou.mybatisplus.core.conditions.Wrapper.class);
|
||||
verify(taskMapper).update(any(), captor.capture());
|
||||
@SuppressWarnings("unchecked")
|
||||
LambdaUpdateWrapper<TranscodeTask> wrapper =
|
||||
(LambdaUpdateWrapper<TranscodeTask>) captor.getValue();
|
||||
assertThat(wrapper.getSqlSet())
|
||||
.containsIgnoringCase("status")
|
||||
.containsIgnoringCase("error_msg")
|
||||
.containsIgnoringCase("finished_at");
|
||||
Map<String, Object> params = wrapper.getParamNameValuePairs();
|
||||
assertThat(params.values()).contains("FAILED");
|
||||
assertThat(params.values())
|
||||
.anyMatch(value -> String.valueOf(value).contains("UNIQUE_TAIL_DUPLICATE_KEY"));
|
||||
}
|
||||
|
||||
private TranscodeTask task(Long id, String status) {
|
||||
TranscodeTask task = new TranscodeTask();
|
||||
task.setId(id);
|
||||
|
||||
+27
-12
@@ -15,7 +15,10 @@ import com.fruitshorts.media.service.transcode.TranscodeRunner;
|
||||
import com.fruitshorts.media.service.transcode.TranscodeWorker;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.boot.test.system.CapturedOutput;
|
||||
import org.springframework.boot.test.system.OutputCaptureExtension;
|
||||
|
||||
import java.nio.file.Paths;
|
||||
import java.util.List;
|
||||
@@ -28,6 +31,7 @@ import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.BDDMockito.given;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
/**
|
||||
@@ -35,6 +39,7 @@ import static org.mockito.Mockito.verify;
|
||||
*
|
||||
* <p>用替身 `TranscodeRunner`,所以**不依赖 ffmpeg**;真实 ffmpeg 的验证在 ffmpeg smoke 测试里。
|
||||
*/
|
||||
@ExtendWith(OutputCaptureExtension.class)
|
||||
class TranscodeWorkerTest {
|
||||
|
||||
private static final Long TASK_ID = 88L;
|
||||
@@ -138,33 +143,43 @@ class TranscodeWorkerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_keep_task_queued_and_record_reason_when_transcode_fails_before_max_retry() {
|
||||
void should_mark_task_failed_immediately_when_transcode_fails() {
|
||||
TranscodeTask task = queuedTask();
|
||||
task.setRetryCount(1);
|
||||
given(transcodeService.claimNext()).willReturn(task);
|
||||
given(transcodeService.fail(eq(TASK_ID), anyString())).willReturn(TranscodeService.STATUS_QUEUED);
|
||||
runner.failure = new IllegalStateException("ffmpeg 退出码 1:Invalid data found");
|
||||
|
||||
worker.processOne();
|
||||
|
||||
verify(transcodeService).fail(eq(TASK_ID), contains("Invalid data found"));
|
||||
// 还会重试,所以媒资不能置 failed(仍期待下一次成功)
|
||||
verify(mediaService, never()).markFailed(any(), anyString());
|
||||
verify(transcodeService).failFinal(eq(TASK_ID), contains("Invalid data found"));
|
||||
verify(mediaService).markFailed(eq(ASSET_ID), contains("Invalid data found"));
|
||||
verify(mediaService, never()).markReady(any(), anyString(), anyString(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_mark_asset_failed_when_retries_are_exhausted() {
|
||||
void should_mark_asset_failed_when_rendition_write_throws_duplicate_key() {
|
||||
TranscodeTask task = queuedTask();
|
||||
task.setRetryCount(2);
|
||||
given(transcodeService.claimNext()).willReturn(task);
|
||||
given(transcodeService.fail(eq(TASK_ID), anyString())).willReturn(TranscodeService.STATUS_FAILED);
|
||||
runner.failure = new IllegalStateException("ffmpeg 未产出播放列表");
|
||||
doThrow(new IllegalStateException(
|
||||
"duplicate key uk_rendition"))
|
||||
.when(transcodeService).succeed(eq(TASK_ID), any(), any());
|
||||
|
||||
worker.processOne();
|
||||
|
||||
verify(transcodeService).fail(eq(TASK_ID), contains("未产出播放列表"));
|
||||
verify(mediaService).markFailed(eq(ASSET_ID), contains("未产出播放列表"));
|
||||
verify(transcodeService).failFinal(eq(TASK_ID), contains("uk_rendition"));
|
||||
verify(mediaService).markFailed(eq(ASSET_ID), contains("uk_rendition"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_log_error_without_escaping_when_failure_persistence_throws(CapturedOutput output) {
|
||||
given(transcodeService.claimNext()).willReturn(queuedTask());
|
||||
runner.failure = new IllegalStateException("ffmpeg failed");
|
||||
doThrow(new IllegalStateException("database unavailable"))
|
||||
.when(transcodeService).failFinal(eq(TASK_ID), anyString());
|
||||
|
||||
worker.processOne();
|
||||
|
||||
assertThat(output).contains("失败状态落库失败").contains("database unavailable");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -175,7 +190,7 @@ class TranscodeWorkerTest {
|
||||
assertThat(worker.processOne()).isFalse();
|
||||
assertThat(runner.called).isFalse();
|
||||
verify(transcodeService, never()).succeed(any(), any());
|
||||
verify(transcodeService, never()).fail(any(), anyString());
|
||||
verify(transcodeService, never()).failFinal(any(), anyString());
|
||||
}
|
||||
|
||||
private TranscodeTask queuedTask() {
|
||||
|
||||
Reference in new issue
Block a user