forked from jsl/video_editing_poc
• I updated the milestone section in docs/video-clipping-service-implementation-plan.md:688 to mark what is already done and to show Retry and DLQ handling as in progress rather than untouched.
I also started that next step in code. The database-backed queue now retries failed jobs with configurable backoff and moves messages to a terminal DLQ status after the configured max attempts in src/main/java/org/example/ videoclips/queue/DatabaseBackedClipJobQueueAdapter.java:48. The new queue settings live in src/main/java/org/example/videoclips/config/VideoClippingProperties.java:133, and I added focused coverage in src/test/java/org/ example/videoclips/queue/DatabaseBackedClipJobQueueAdapterTest.java:1. Verification: mvn -q test passed. - VideoAssetControllerTest: 7/7 - RetentionCleanupJobTest: 2/2 - DatabaseBackedClipJobQueueAdapterTest: 2/2
This commit is contained in:
parent
0d45f898f7
commit
8548542248
|
|
@ -689,42 +689,42 @@ Keep the domain independent from Spring framework details. Adapters implement st
|
||||||
|
|
||||||
### MVP
|
### MVP
|
||||||
|
|
||||||
1. Create asset and upload-session API.
|
1. [x] Create asset and upload-session API.
|
||||||
2. Direct S3 multipart upload support.
|
2. [x] Direct S3 multipart upload support.
|
||||||
3. Complete upload endpoint.
|
3. [x] Complete upload endpoint.
|
||||||
4. Create clip job endpoint.
|
4. [x] Create clip job endpoint.
|
||||||
5. Queue publisher and single worker.
|
5. [x] Queue publisher and single worker.
|
||||||
6. FFmpeg `FAST` mode.
|
6. [x] FFmpeg `FAST` mode.
|
||||||
7. Job status and clip listing.
|
7. [x] Job status and clip listing.
|
||||||
8. Basic integration tests.
|
8. [x] Basic integration tests.
|
||||||
|
|
||||||
### Production Hardening
|
### Production Hardening
|
||||||
|
|
||||||
1. `EXACT` clipping mode.
|
1. [x] `EXACT` clipping mode.
|
||||||
2. Idempotency keys.
|
2. [x] Idempotency keys.
|
||||||
3. Retry and DLQ handling.
|
3. [~] Retry and DLQ handling.
|
||||||
4. Tenant quotas.
|
4. [ ] Tenant quotas.
|
||||||
5. Full `application/problem+json` error model.
|
5. [~] Full `application/problem+json` error model.
|
||||||
6. Cleanup lifecycle jobs.
|
6. [x] Cleanup lifecycle jobs.
|
||||||
7. Security review.
|
7. [~] Security review.
|
||||||
|
|
||||||
### Performance Tuning
|
### Performance Tuning
|
||||||
|
|
||||||
1. Worker CPU/disk benchmarks.
|
1. [ ] Worker CPU/disk benchmarks.
|
||||||
2. FFmpeg preset benchmarks.
|
2. [ ] FFmpeg preset benchmarks.
|
||||||
3. Object-storage bandwidth tests.
|
3. [ ] Object-storage bandwidth tests.
|
||||||
4. Queue visibility-timeout tuning.
|
4. [ ] Queue visibility-timeout tuning.
|
||||||
5. Autoscaling policies.
|
5. [ ] Autoscaling policies.
|
||||||
6. Cost model by video minute.
|
6. [ ] Cost model by video minute.
|
||||||
|
|
||||||
### Operational Readiness
|
### Operational Readiness
|
||||||
|
|
||||||
1. Dashboards.
|
1. [ ] Dashboards.
|
||||||
2. Alerts.
|
2. [ ] Alerts.
|
||||||
3. Runbooks.
|
3. [ ] Runbooks.
|
||||||
4. Backup and restore validation.
|
4. [ ] Backup and restore validation.
|
||||||
5. DLQ redrive procedure.
|
5. [ ] DLQ redrive procedure.
|
||||||
6. Load-test signoff.
|
6. [ ] Load-test signoff.
|
||||||
|
|
||||||
## 14. Open Questions
|
## 14. Open Questions
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -164,6 +164,12 @@ public class VideoClippingProperties {
|
||||||
@Min(1)
|
@Min(1)
|
||||||
private int batchSize = 5;
|
private int batchSize = 5;
|
||||||
|
|
||||||
|
@Min(1)
|
||||||
|
private int maxAttempts = 3;
|
||||||
|
|
||||||
|
@Min(0)
|
||||||
|
private long retryBackoffMs = 5000;
|
||||||
|
|
||||||
public long getPollIntervalMs() {
|
public long getPollIntervalMs() {
|
||||||
return pollIntervalMs;
|
return pollIntervalMs;
|
||||||
}
|
}
|
||||||
|
|
@ -179,6 +185,22 @@ public class VideoClippingProperties {
|
||||||
public void setBatchSize(int batchSize) {
|
public void setBatchSize(int batchSize) {
|
||||||
this.batchSize = batchSize;
|
this.batchSize = batchSize;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public int getMaxAttempts() {
|
||||||
|
return maxAttempts;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMaxAttempts(int maxAttempts) {
|
||||||
|
this.maxAttempts = maxAttempts;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getRetryBackoffMs() {
|
||||||
|
return retryBackoffMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRetryBackoffMs(long retryBackoffMs) {
|
||||||
|
this.retryBackoffMs = retryBackoffMs;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class Ffmpeg {
|
public static class Ffmpeg {
|
||||||
|
|
|
||||||
|
|
@ -47,14 +47,15 @@ public class DatabaseBackedClipJobQueueAdapter implements ClipJobQueuePort {
|
||||||
@Transactional
|
@Transactional
|
||||||
public void poll() {
|
public void poll() {
|
||||||
int remaining = properties.getDatabaseQueue().getBatchSize();
|
int remaining = properties.getDatabaseQueue().getBatchSize();
|
||||||
|
Instant now = Instant.now();
|
||||||
List<QueueMessageEntity> messages = queueMessageJpaRepository
|
List<QueueMessageEntity> messages = queueMessageJpaRepository
|
||||||
.findTop10ByStatusAndAvailableAtLessThanEqualOrderByAvailableAtAsc("PENDING", Instant.now());
|
.findTop10ByStatusAndAvailableAtLessThanEqualOrderByAvailableAtAsc("PENDING", now);
|
||||||
for (QueueMessageEntity message : messages) {
|
for (QueueMessageEntity message : messages) {
|
||||||
if (remaining-- <= 0) {
|
if (remaining-- <= 0) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
message.setStatus("PROCESSING");
|
message.setStatus("PROCESSING");
|
||||||
message.setClaimedAt(Instant.now());
|
message.setClaimedAt(now);
|
||||||
message.setAttemptCount(message.getAttemptCount() + 1);
|
message.setAttemptCount(message.getAttemptCount() + 1);
|
||||||
queueMessageJpaRepository.save(message);
|
queueMessageJpaRepository.save(message);
|
||||||
try {
|
try {
|
||||||
|
|
@ -62,8 +63,13 @@ public class DatabaseBackedClipJobQueueAdapter implements ClipJobQueuePort {
|
||||||
message.setStatus("COMPLETED");
|
message.setStatus("COMPLETED");
|
||||||
message.setLastError(null);
|
message.setLastError(null);
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
message.setStatus("FAILED");
|
|
||||||
message.setLastError(ex.getMessage());
|
message.setLastError(ex.getMessage());
|
||||||
|
if (message.getAttemptCount() >= properties.getDatabaseQueue().getMaxAttempts()) {
|
||||||
|
message.setStatus("DLQ");
|
||||||
|
} else {
|
||||||
|
message.setStatus("PENDING");
|
||||||
|
message.setAvailableAt(now.plusMillis(properties.getDatabaseQueue().getRetryBackoffMs()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
queueMessageJpaRepository.save(message);
|
queueMessageJpaRepository.save(message);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,81 @@
|
||||||
|
package org.example.videoclips.queue;
|
||||||
|
|
||||||
|
import org.example.videoclips.config.VideoClippingProperties;
|
||||||
|
import org.example.videoclips.processing.ClipProcessor;
|
||||||
|
import org.example.videoclips.queue.entity.QueueMessageEntity;
|
||||||
|
import org.example.videoclips.queue.spring.QueueMessageJpaRepository;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.doThrow;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.times;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
class DatabaseBackedClipJobQueueAdapterTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void retriesFailedMessagesWithBackoffBeforeSendingToDlq() {
|
||||||
|
QueueMessageJpaRepository repository = mock(QueueMessageJpaRepository.class);
|
||||||
|
ClipProcessor clipProcessor = mock(ClipProcessor.class);
|
||||||
|
VideoClippingProperties properties = new VideoClippingProperties();
|
||||||
|
properties.getDatabaseQueue().setMaxAttempts(3);
|
||||||
|
properties.getDatabaseQueue().setRetryBackoffMs(5000);
|
||||||
|
|
||||||
|
QueueMessageEntity firstAttempt = new QueueMessageEntity();
|
||||||
|
firstAttempt.setId("msg_1");
|
||||||
|
firstAttempt.setJobId("job_1");
|
||||||
|
firstAttempt.setStatus("PENDING");
|
||||||
|
firstAttempt.setAttemptCount(0);
|
||||||
|
firstAttempt.setAvailableAt(Instant.now().minusSeconds(1));
|
||||||
|
|
||||||
|
when(repository.findTop10ByStatusAndAvailableAtLessThanEqualOrderByAvailableAtAsc(any(), any()))
|
||||||
|
.thenReturn(List.of(firstAttempt));
|
||||||
|
doThrow(new IllegalStateException("processing failed")).when(clipProcessor).processNow("job_1");
|
||||||
|
|
||||||
|
DatabaseBackedClipJobQueueAdapter adapter = new DatabaseBackedClipJobQueueAdapter(repository, clipProcessor, properties);
|
||||||
|
|
||||||
|
adapter.poll();
|
||||||
|
|
||||||
|
assertEquals(1, firstAttempt.getAttemptCount());
|
||||||
|
assertEquals("PENDING", firstAttempt.getStatus());
|
||||||
|
assertEquals("processing failed", firstAttempt.getLastError());
|
||||||
|
assertNotNull(firstAttempt.getClaimedAt());
|
||||||
|
verify(repository, times(2)).save(firstAttempt);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void movesMessageToDlqAfterMaxAttempts() {
|
||||||
|
QueueMessageJpaRepository repository = mock(QueueMessageJpaRepository.class);
|
||||||
|
ClipProcessor clipProcessor = mock(ClipProcessor.class);
|
||||||
|
VideoClippingProperties properties = new VideoClippingProperties();
|
||||||
|
properties.getDatabaseQueue().setMaxAttempts(3);
|
||||||
|
properties.getDatabaseQueue().setRetryBackoffMs(5000);
|
||||||
|
|
||||||
|
QueueMessageEntity finalAttempt = new QueueMessageEntity();
|
||||||
|
finalAttempt.setId("msg_2");
|
||||||
|
finalAttempt.setJobId("job_2");
|
||||||
|
finalAttempt.setStatus("PENDING");
|
||||||
|
finalAttempt.setAttemptCount(2);
|
||||||
|
finalAttempt.setAvailableAt(Instant.now().minusSeconds(1));
|
||||||
|
|
||||||
|
when(repository.findTop10ByStatusAndAvailableAtLessThanEqualOrderByAvailableAtAsc(any(), any()))
|
||||||
|
.thenReturn(List.of(finalAttempt));
|
||||||
|
doThrow(new IllegalStateException("processing failed")).when(clipProcessor).processNow("job_2");
|
||||||
|
|
||||||
|
DatabaseBackedClipJobQueueAdapter adapter = new DatabaseBackedClipJobQueueAdapter(repository, clipProcessor, properties);
|
||||||
|
|
||||||
|
adapter.poll();
|
||||||
|
|
||||||
|
assertEquals(3, finalAttempt.getAttemptCount());
|
||||||
|
assertEquals("DLQ", finalAttempt.getStatus());
|
||||||
|
assertEquals("processing failed", finalAttempt.getLastError());
|
||||||
|
verify(repository, times(2)).save(finalAttempt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
mock-maker-subclass
|
||||||
Loading…
Reference in New Issue