forked from jsl/video_editing_poc
I completed the next plan step, Tenant quotas, and updated the plan to mark it done in docs/video-clipping-service-implementation-plan.md:701.
The implementation adds tenant-aware assets/jobs plus a configurable per-tenant active-job quota. The main enforcement is in src/main/java/org/example/videoclips/application/VideoAssetService.java:165, backed by tenant fields added to the domain/persistence model and migration src/main/resources/db/migration/V6__tenant_quota_support.sql:1. Requests can set X-Tenant-Id on asset creation, quota config lives in src/main/java/org/example/videoclips/ config/VideoClippingProperties.java:39, and quota violations now return 429 via src/main/java/org/example/videoclips/api/ApiExceptionHandler.java:44.
This commit is contained in:
parent
8548542248
commit
1bd879f1e2
|
|
@ -703,7 +703,7 @@ Keep the domain independent from Spring framework details. Adapters implement st
|
||||||
1. [x] `EXACT` clipping mode.
|
1. [x] `EXACT` clipping mode.
|
||||||
2. [x] Idempotency keys.
|
2. [x] Idempotency keys.
|
||||||
3. [~] Retry and DLQ handling.
|
3. [~] Retry and DLQ handling.
|
||||||
4. [ ] Tenant quotas.
|
4. [x] Tenant quotas.
|
||||||
5. [~] Full `application/problem+json` error model.
|
5. [~] Full `application/problem+json` error model.
|
||||||
6. [x] Cleanup lifecycle jobs.
|
6. [x] Cleanup lifecycle jobs.
|
||||||
7. [~] Security review.
|
7. [~] Security review.
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import jakarta.servlet.http.HttpServletRequest;
|
||||||
import jakarta.validation.ConstraintViolationException;
|
import jakarta.validation.ConstraintViolationException;
|
||||||
import org.example.videoclips.application.NotFoundException;
|
import org.example.videoclips.application.NotFoundException;
|
||||||
import org.example.videoclips.application.PayloadTooLargeException;
|
import org.example.videoclips.application.PayloadTooLargeException;
|
||||||
|
import org.example.videoclips.application.QuotaExceededException;
|
||||||
import org.example.videoclips.application.StateConflictException;
|
import org.example.videoclips.application.StateConflictException;
|
||||||
import org.example.videoclips.application.UnsupportedMediaTypeException;
|
import org.example.videoclips.application.UnsupportedMediaTypeException;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
|
|
@ -45,6 +46,11 @@ public class ApiExceptionHandler extends ResponseEntityExceptionHandler {
|
||||||
return problem(HttpStatus.UNSUPPORTED_MEDIA_TYPE, "unsupported-media-type", ex.getMessage(), request.getRequestURI(), null);
|
return problem(HttpStatus.UNSUPPORTED_MEDIA_TYPE, "unsupported-media-type", ex.getMessage(), request.getRequestURI(), null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(QuotaExceededException.class)
|
||||||
|
ProblemDetail handleQuotaExceeded(QuotaExceededException ex, HttpServletRequest request) {
|
||||||
|
return problem(HttpStatus.TOO_MANY_REQUESTS, "quota-exceeded", ex.getMessage(), request.getRequestURI(), null);
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected ResponseEntity<Object> handleMethodArgumentNotValid(
|
protected ResponseEntity<Object> handleMethodArgumentNotValid(
|
||||||
MethodArgumentNotValidException ex,
|
MethodArgumentNotValidException ex,
|
||||||
|
|
|
||||||
|
|
@ -30,9 +30,10 @@ public class VideoAssetController {
|
||||||
@ResponseStatus(HttpStatus.CREATED)
|
@ResponseStatus(HttpStatus.CREATED)
|
||||||
public Object createAsset(
|
public Object createAsset(
|
||||||
@Valid @RequestBody CreateVideoAssetRequest request,
|
@Valid @RequestBody CreateVideoAssetRequest request,
|
||||||
@RequestHeader(value = "Idempotency-Key", required = false) String idempotencyKey
|
@RequestHeader(value = "Idempotency-Key", required = false) String idempotencyKey,
|
||||||
|
@RequestHeader(value = "X-Tenant-Id", required = false) String tenantId
|
||||||
) {
|
) {
|
||||||
return videoAssetService.createAsset(request, idempotencyKey);
|
return videoAssetService.createAsset(request, idempotencyKey, tenantId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/{assetId}/uploads:complete")
|
@PostMapping("/{assetId}/uploads:complete")
|
||||||
|
|
@ -65,9 +66,10 @@ public class VideoAssetController {
|
||||||
public Object createClipJob(
|
public Object createClipJob(
|
||||||
@PathVariable String assetId,
|
@PathVariable String assetId,
|
||||||
@Valid @RequestBody ClipJobRequest request,
|
@Valid @RequestBody ClipJobRequest request,
|
||||||
@RequestHeader(value = "Idempotency-Key", required = false) String idempotencyKey
|
@RequestHeader(value = "Idempotency-Key", required = false) String idempotencyKey,
|
||||||
|
@RequestHeader(value = "X-Tenant-Id", required = false) String tenantId
|
||||||
) {
|
) {
|
||||||
return videoAssetService.createClipJob(assetId, request, idempotencyKey);
|
return videoAssetService.createClipJob(assetId, request, idempotencyKey, tenantId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@DeleteMapping("/{assetId}")
|
@DeleteMapping("/{assetId}")
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
package org.example.videoclips.application;
|
||||||
|
|
||||||
|
public class QuotaExceededException extends RuntimeException {
|
||||||
|
|
||||||
|
public QuotaExceededException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -26,6 +26,7 @@ import java.util.UUID;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class VideoAssetService {
|
public class VideoAssetService {
|
||||||
|
private static final String DEFAULT_TENANT_ID = "default";
|
||||||
|
|
||||||
private final VideoClippingRepository repository;
|
private final VideoClippingRepository repository;
|
||||||
private final ObjectStoragePort objectStoragePort;
|
private final ObjectStoragePort objectStoragePort;
|
||||||
|
|
@ -44,7 +45,7 @@ public class VideoAssetService {
|
||||||
this.properties = properties;
|
this.properties = properties;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Map<String, Object> createAsset(CreateVideoAssetRequest request, String idempotencyKey) {
|
public Map<String, Object> createAsset(CreateVideoAssetRequest request, String idempotencyKey, String tenantId) {
|
||||||
if (request.contentLengthBytes() > properties.getMaxFileSizeBytes()) {
|
if (request.contentLengthBytes() > properties.getMaxFileSizeBytes()) {
|
||||||
throw new PayloadTooLargeException("contentLengthBytes exceeds configured max file size");
|
throw new PayloadTooLargeException("contentLengthBytes exceeds configured max file size");
|
||||||
}
|
}
|
||||||
|
|
@ -52,7 +53,8 @@ public class VideoAssetService {
|
||||||
throw new UnsupportedMediaTypeException("contentType is not allowed");
|
throw new UnsupportedMediaTypeException("contentType is not allowed");
|
||||||
}
|
}
|
||||||
|
|
||||||
String dedupeKey = normalizeKey(idempotencyKey, "asset:" + request.fileName() + ":" + request.contentLengthBytes());
|
String normalizedTenantId = normalizeTenantId(tenantId);
|
||||||
|
String dedupeKey = normalizeKey(idempotencyKey, normalizedTenantId + ":asset:" + request.fileName() + ":" + request.contentLengthBytes());
|
||||||
Map<String, Object> existing = repository.findAssetResponseByIdempotencyKey(dedupeKey);
|
Map<String, Object> existing = repository.findAssetResponseByIdempotencyKey(dedupeKey);
|
||||||
if (existing != null) {
|
if (existing != null) {
|
||||||
return existing;
|
return existing;
|
||||||
|
|
@ -65,6 +67,7 @@ public class VideoAssetService {
|
||||||
|
|
||||||
VideoAsset asset = new VideoAsset(
|
VideoAsset asset = new VideoAsset(
|
||||||
assetId,
|
assetId,
|
||||||
|
normalizedTenantId,
|
||||||
request.fileName(),
|
request.fileName(),
|
||||||
request.contentType(),
|
request.contentType(),
|
||||||
request.contentLengthBytes(),
|
request.contentLengthBytes(),
|
||||||
|
|
@ -159,7 +162,7 @@ public class VideoAssetService {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Map<String, Object> createClipJob(String assetId, ClipJobRequest request, String idempotencyKey) {
|
public Map<String, Object> createClipJob(String assetId, ClipJobRequest request, String idempotencyKey, String tenantId) {
|
||||||
VideoAsset asset = getAsset(assetId);
|
VideoAsset asset = getAsset(assetId);
|
||||||
if (asset.status() != VideoAsset.Status.UPLOADED && asset.status() != VideoAsset.Status.READY) {
|
if (asset.status() != VideoAsset.Status.UPLOADED && asset.status() != VideoAsset.Status.READY) {
|
||||||
throw new StateConflictException("asset must be uploaded before a clip job can be created");
|
throw new StateConflictException("asset must be uploaded before a clip job can be created");
|
||||||
|
|
@ -168,13 +171,18 @@ public class VideoAssetService {
|
||||||
throw new StateConflictException("only mp4 output is supported in this implementation");
|
throw new StateConflictException("only mp4 output is supported in this implementation");
|
||||||
}
|
}
|
||||||
|
|
||||||
String dedupeKey = normalizeKey(idempotencyKey, "job:" + assetId + ":" + request.segmentDurationSeconds() + ":" + request.accuracyMode());
|
String effectiveTenantId = asset.tenantId();
|
||||||
|
String dedupeKey = normalizeKey(idempotencyKey, effectiveTenantId + ":job:" + assetId + ":" + request.segmentDurationSeconds() + ":" + request.accuracyMode());
|
||||||
Map<String, Object> existing = repository.findJobResponseByIdempotencyKey(dedupeKey);
|
Map<String, Object> existing = repository.findJobResponseByIdempotencyKey(dedupeKey);
|
||||||
if (existing != null) {
|
if (existing != null) {
|
||||||
return existing;
|
return existing;
|
||||||
}
|
}
|
||||||
|
|
||||||
boolean overwriteExisting = Boolean.TRUE.equals(request.overwriteExisting());
|
boolean overwriteExisting = Boolean.TRUE.equals(request.overwriteExisting());
|
||||||
|
int maxActiveJobsPerTenant = properties.getQuotas().getMaxActiveJobsPerTenant();
|
||||||
|
if (repository.countActiveJobsForTenant(effectiveTenantId) >= maxActiveJobsPerTenant) {
|
||||||
|
throw new QuotaExceededException("tenant active job quota exceeded");
|
||||||
|
}
|
||||||
if (!overwriteExisting && repository.hasActiveJobForAsset(assetId)) {
|
if (!overwriteExisting && repository.hasActiveJobForAsset(assetId)) {
|
||||||
throw new StateConflictException("an active clip job already exists for this asset");
|
throw new StateConflictException("an active clip job already exists for this asset");
|
||||||
}
|
}
|
||||||
|
|
@ -182,6 +190,7 @@ public class VideoAssetService {
|
||||||
ClipJob job = new ClipJob(
|
ClipJob job = new ClipJob(
|
||||||
nextId("job"),
|
nextId("job"),
|
||||||
assetId,
|
assetId,
|
||||||
|
effectiveTenantId,
|
||||||
ClipJobStatus.QUEUED,
|
ClipJobStatus.QUEUED,
|
||||||
request.accuracyMode() == null ? AccuracyMode.FAST : request.accuracyMode(),
|
request.accuracyMode() == null ? AccuracyMode.FAST : request.accuracyMode(),
|
||||||
request.segmentDurationSeconds(),
|
request.segmentDurationSeconds(),
|
||||||
|
|
@ -433,6 +442,10 @@ public class VideoAssetService {
|
||||||
return providedKey == null || providedKey.isBlank() ? fallback : providedKey;
|
return providedKey == null || providedKey.isBlank() ? fallback : providedKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String normalizeTenantId(String tenantId) {
|
||||||
|
return tenantId == null || tenantId.isBlank() ? DEFAULT_TENANT_ID : tenantId;
|
||||||
|
}
|
||||||
|
|
||||||
private void appendJobEvent(String aggregateId, String eventType, String message) {
|
private void appendJobEvent(String aggregateId, String eventType, String message) {
|
||||||
repository.saveJobEvent(new JobEvent(nextId("evt"), aggregateId, eventType, message, Instant.now()));
|
repository.saveJobEvent(new JobEvent(nextId("evt"), aggregateId, eventType, message, Instant.now()));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,8 @@ public interface VideoClippingRepository {
|
||||||
|
|
||||||
boolean hasActiveJobForAsset(String assetId);
|
boolean hasActiveJobForAsset(String assetId);
|
||||||
|
|
||||||
|
int countActiveJobsForTenant(String tenantId);
|
||||||
|
|
||||||
Map<String, Object> findAssetResponseByIdempotencyKey(String key);
|
Map<String, Object> findAssetResponseByIdempotencyKey(String key);
|
||||||
|
|
||||||
void storeAssetResponseByIdempotencyKey(String key, Map<String, Object> response);
|
void storeAssetResponseByIdempotencyKey(String key, Map<String, Object> response);
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,8 @@ public class VideoClippingProperties {
|
||||||
|
|
||||||
private final Cleanup cleanup = new Cleanup();
|
private final Cleanup cleanup = new Cleanup();
|
||||||
|
|
||||||
|
private final Quotas quotas = new Quotas();
|
||||||
|
|
||||||
public long getMaxFileSizeBytes() {
|
public long getMaxFileSizeBytes() {
|
||||||
return maxFileSizeBytes;
|
return maxFileSizeBytes;
|
||||||
}
|
}
|
||||||
|
|
@ -118,6 +120,10 @@ public class VideoClippingProperties {
|
||||||
return cleanup;
|
return cleanup;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Quotas getQuotas() {
|
||||||
|
return quotas;
|
||||||
|
}
|
||||||
|
|
||||||
public static class S3 {
|
public static class S3 {
|
||||||
private String region = "us-east-1";
|
private String region = "us-east-1";
|
||||||
private String bucket = "video-clipping-dev";
|
private String bucket = "video-clipping-dev";
|
||||||
|
|
@ -308,4 +314,17 @@ public class VideoClippingProperties {
|
||||||
this.clipRetentionHours = clipRetentionHours;
|
this.clipRetentionHours = clipRetentionHours;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static class Quotas {
|
||||||
|
@Min(0)
|
||||||
|
private int maxActiveJobsPerTenant = 5;
|
||||||
|
|
||||||
|
public int getMaxActiveJobsPerTenant() {
|
||||||
|
return maxActiveJobsPerTenant;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMaxActiveJobsPerTenant(int maxActiveJobsPerTenant) {
|
||||||
|
this.maxActiveJobsPerTenant = maxActiveJobsPerTenant;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import java.time.Instant;
|
||||||
public record ClipJob(
|
public record ClipJob(
|
||||||
String id,
|
String id,
|
||||||
String assetId,
|
String assetId,
|
||||||
|
String tenantId,
|
||||||
ClipJobStatus status,
|
ClipJobStatus status,
|
||||||
AccuracyMode accuracyMode,
|
AccuracyMode accuracyMode,
|
||||||
int segmentDurationSeconds,
|
int segmentDurationSeconds,
|
||||||
|
|
@ -16,27 +17,27 @@ public record ClipJob(
|
||||||
String errorMessage
|
String errorMessage
|
||||||
) {
|
) {
|
||||||
public ClipJob withStatus(ClipJobStatus newStatus) {
|
public ClipJob withStatus(ClipJobStatus newStatus) {
|
||||||
return new ClipJob(id, assetId, newStatus, accuracyMode, segmentDurationSeconds, progressPercent, attemptCount,
|
return new ClipJob(id, assetId, tenantId, newStatus, accuracyMode, segmentDurationSeconds, progressPercent, attemptCount,
|
||||||
createdAt, startedAt, finishedAt, errorMessage);
|
createdAt, startedAt, finishedAt, errorMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
public ClipJob withProgress(int progress) {
|
public ClipJob withProgress(int progress) {
|
||||||
return new ClipJob(id, assetId, status, accuracyMode, segmentDurationSeconds, progress, attemptCount,
|
return new ClipJob(id, assetId, tenantId, status, accuracyMode, segmentDurationSeconds, progress, attemptCount,
|
||||||
createdAt, startedAt, finishedAt, errorMessage);
|
createdAt, startedAt, finishedAt, errorMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
public ClipJob withStartedAt(Instant startedAtValue) {
|
public ClipJob withStartedAt(Instant startedAtValue) {
|
||||||
return new ClipJob(id, assetId, status, accuracyMode, segmentDurationSeconds, progressPercent, attemptCount,
|
return new ClipJob(id, assetId, tenantId, status, accuracyMode, segmentDurationSeconds, progressPercent, attemptCount,
|
||||||
createdAt, startedAtValue, finishedAt, errorMessage);
|
createdAt, startedAtValue, finishedAt, errorMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
public ClipJob withFinishedAt(Instant finishedAtValue) {
|
public ClipJob withFinishedAt(Instant finishedAtValue) {
|
||||||
return new ClipJob(id, assetId, status, accuracyMode, segmentDurationSeconds, progressPercent, attemptCount,
|
return new ClipJob(id, assetId, tenantId, status, accuracyMode, segmentDurationSeconds, progressPercent, attemptCount,
|
||||||
createdAt, startedAt, finishedAtValue, errorMessage);
|
createdAt, startedAt, finishedAtValue, errorMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
public ClipJob withErrorMessage(String message) {
|
public ClipJob withErrorMessage(String message) {
|
||||||
return new ClipJob(id, assetId, status, accuracyMode, segmentDurationSeconds, progressPercent, attemptCount,
|
return new ClipJob(id, assetId, tenantId, status, accuracyMode, segmentDurationSeconds, progressPercent, attemptCount,
|
||||||
createdAt, startedAt, finishedAt, message);
|
createdAt, startedAt, finishedAt, message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import java.time.Instant;
|
||||||
|
|
||||||
public record VideoAsset(
|
public record VideoAsset(
|
||||||
String id,
|
String id,
|
||||||
|
String tenantId,
|
||||||
String fileName,
|
String fileName,
|
||||||
String contentType,
|
String contentType,
|
||||||
long contentLengthBytes,
|
long contentLengthBytes,
|
||||||
|
|
@ -26,12 +27,12 @@ public record VideoAsset(
|
||||||
}
|
}
|
||||||
|
|
||||||
public VideoAsset withUploadCompleted(long durationSeconds, Instant uploadedAt) {
|
public VideoAsset withUploadCompleted(long durationSeconds, Instant uploadedAt) {
|
||||||
return new VideoAsset(id, fileName, contentType, contentLengthBytes, checksumSha256, clipProfile, sourceObjectKey,
|
return new VideoAsset(id, tenantId, fileName, contentType, contentLengthBytes, checksumSha256, clipProfile, sourceObjectKey,
|
||||||
Status.UPLOADED, uploadId, durationSeconds, createdAt, uploadedAt, expiresAt);
|
Status.UPLOADED, uploadId, durationSeconds, createdAt, uploadedAt, expiresAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
public VideoAsset withStatus(Status status) {
|
public VideoAsset withStatus(Status status) {
|
||||||
return new VideoAsset(id, fileName, contentType, contentLengthBytes, checksumSha256, clipProfile, sourceObjectKey,
|
return new VideoAsset(id, tenantId, fileName, contentType, contentLengthBytes, checksumSha256, clipProfile, sourceObjectKey,
|
||||||
status, uploadId, sourceDurationSeconds, createdAt, uploadedAt, expiresAt);
|
status, uploadId, sourceDurationSeconds, createdAt, uploadedAt, expiresAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -40,7 +41,7 @@ public record VideoAsset(
|
||||||
}
|
}
|
||||||
|
|
||||||
public VideoAsset withExpiresAt(Instant expiration) {
|
public VideoAsset withExpiresAt(Instant expiration) {
|
||||||
return new VideoAsset(id, fileName, contentType, contentLengthBytes, checksumSha256, clipProfile, sourceObjectKey,
|
return new VideoAsset(id, tenantId, fileName, contentType, contentLengthBytes, checksumSha256, clipProfile, sourceObjectKey,
|
||||||
status, uploadId, sourceDurationSeconds, createdAt, uploadedAt, expiration);
|
status, uploadId, sourceDurationSeconds, createdAt, uploadedAt, expiration);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -142,6 +142,16 @@ public class InMemoryVideoAssetRepository implements VideoClippingRepository {
|
||||||
&& job.status() != ClipJobStatus.CANCELLED);
|
&& job.status() != ClipJobStatus.CANCELLED);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int countActiveJobsForTenant(String tenantId) {
|
||||||
|
return (int) jobs.values().stream()
|
||||||
|
.filter(job -> job.tenantId().equals(tenantId))
|
||||||
|
.filter(job -> job.status() != ClipJobStatus.SUCCEEDED
|
||||||
|
&& job.status() != ClipJobStatus.FAILED
|
||||||
|
&& job.status() != ClipJobStatus.CANCELLED)
|
||||||
|
.count();
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Map<String, Object> findAssetResponseByIdempotencyKey(String key) {
|
public Map<String, Object> findAssetResponseByIdempotencyKey(String key) {
|
||||||
return assetResponsesByIdempotencyKey.get(key);
|
return assetResponsesByIdempotencyKey.get(key);
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ public class JpaVideoClippingMapper {
|
||||||
public VideoAssetEntity toEntity(VideoAsset asset) {
|
public VideoAssetEntity toEntity(VideoAsset asset) {
|
||||||
VideoAssetEntity entity = new VideoAssetEntity();
|
VideoAssetEntity entity = new VideoAssetEntity();
|
||||||
entity.setId(asset.id());
|
entity.setId(asset.id());
|
||||||
|
entity.setTenantId(asset.tenantId());
|
||||||
entity.setFileName(asset.fileName());
|
entity.setFileName(asset.fileName());
|
||||||
entity.setContentType(asset.contentType());
|
entity.setContentType(asset.contentType());
|
||||||
entity.setContentLengthBytes(asset.contentLengthBytes());
|
entity.setContentLengthBytes(asset.contentLengthBytes());
|
||||||
|
|
@ -38,6 +39,7 @@ public class JpaVideoClippingMapper {
|
||||||
public VideoAsset toDomain(VideoAssetEntity entity) {
|
public VideoAsset toDomain(VideoAssetEntity entity) {
|
||||||
return new VideoAsset(
|
return new VideoAsset(
|
||||||
entity.getId(),
|
entity.getId(),
|
||||||
|
entity.getTenantId(),
|
||||||
entity.getFileName(),
|
entity.getFileName(),
|
||||||
entity.getContentType(),
|
entity.getContentType(),
|
||||||
entity.getContentLengthBytes(),
|
entity.getContentLengthBytes(),
|
||||||
|
|
@ -83,6 +85,7 @@ public class JpaVideoClippingMapper {
|
||||||
ClipJobEntity entity = new ClipJobEntity();
|
ClipJobEntity entity = new ClipJobEntity();
|
||||||
entity.setId(job.id());
|
entity.setId(job.id());
|
||||||
entity.setAssetId(job.assetId());
|
entity.setAssetId(job.assetId());
|
||||||
|
entity.setTenantId(job.tenantId());
|
||||||
entity.setStatus(job.status().name());
|
entity.setStatus(job.status().name());
|
||||||
entity.setAccuracyMode(job.accuracyMode().name());
|
entity.setAccuracyMode(job.accuracyMode().name());
|
||||||
entity.setSegmentDurationSeconds(job.segmentDurationSeconds());
|
entity.setSegmentDurationSeconds(job.segmentDurationSeconds());
|
||||||
|
|
@ -99,6 +102,7 @@ public class JpaVideoClippingMapper {
|
||||||
return new ClipJob(
|
return new ClipJob(
|
||||||
entity.getId(),
|
entity.getId(),
|
||||||
entity.getAssetId(),
|
entity.getAssetId(),
|
||||||
|
entity.getTenantId(),
|
||||||
ClipJobStatus.valueOf(entity.getStatus()),
|
ClipJobStatus.valueOf(entity.getStatus()),
|
||||||
AccuracyMode.valueOf(entity.getAccuracyMode()),
|
AccuracyMode.valueOf(entity.getAccuracyMode()),
|
||||||
entity.getSegmentDurationSeconds(),
|
entity.getSegmentDurationSeconds(),
|
||||||
|
|
|
||||||
|
|
@ -152,6 +152,11 @@ public class JpaVideoClippingRepository implements VideoClippingRepository {
|
||||||
&& job.status() != ClipJobStatus.CANCELLED);
|
&& job.status() != ClipJobStatus.CANCELLED);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int countActiveJobsForTenant(String tenantId) {
|
||||||
|
return Math.toIntExact(clipJobJpaRepository.countActiveByTenantId(tenantId));
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Map<String, Object> findAssetResponseByIdempotencyKey(String key) {
|
public Map<String, Object> findAssetResponseByIdempotencyKey(String key) {
|
||||||
return assetIdempotencyJpaRepository.findById(key)
|
return assetIdempotencyJpaRepository.findById(key)
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,9 @@ public class ClipJobEntity {
|
||||||
@Column(nullable = false, length = 64)
|
@Column(nullable = false, length = 64)
|
||||||
private String assetId;
|
private String assetId;
|
||||||
|
|
||||||
|
@Column(nullable = false, length = 64)
|
||||||
|
private String tenantId;
|
||||||
|
|
||||||
@Column(nullable = false, length = 32)
|
@Column(nullable = false, length = 32)
|
||||||
private String status;
|
private String status;
|
||||||
|
|
||||||
|
|
@ -62,6 +65,14 @@ public class ClipJobEntity {
|
||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getTenantId() {
|
||||||
|
return tenantId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTenantId(String tenantId) {
|
||||||
|
this.tenantId = tenantId;
|
||||||
|
}
|
||||||
|
|
||||||
public void setStatus(String status) {
|
public void setStatus(String status) {
|
||||||
this.status = status;
|
this.status = status;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,9 @@ public class VideoAssetEntity {
|
||||||
@Id
|
@Id
|
||||||
private String id;
|
private String id;
|
||||||
|
|
||||||
|
@Column(nullable = false, length = 64)
|
||||||
|
private String tenantId;
|
||||||
|
|
||||||
@Column(nullable = false, length = 255)
|
@Column(nullable = false, length = 255)
|
||||||
private String fileName;
|
private String fileName;
|
||||||
|
|
||||||
|
|
@ -59,6 +62,14 @@ public class VideoAssetEntity {
|
||||||
return fileName;
|
return fileName;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getTenantId() {
|
||||||
|
return tenantId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTenantId(String tenantId) {
|
||||||
|
this.tenantId = tenantId;
|
||||||
|
}
|
||||||
|
|
||||||
public void setFileName(String fileName) {
|
public void setFileName(String fileName) {
|
||||||
this.fileName = fileName;
|
this.fileName = fileName;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,19 @@ package org.example.videoclips.persistence.spring;
|
||||||
|
|
||||||
import org.example.videoclips.persistence.entity.ClipJobEntity;
|
import org.example.videoclips.persistence.entity.ClipJobEntity;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
public interface ClipJobJpaRepository extends JpaRepository<ClipJobEntity, String> {
|
public interface ClipJobJpaRepository extends JpaRepository<ClipJobEntity, String> {
|
||||||
|
|
||||||
List<ClipJobEntity> findByAssetId(String assetId);
|
List<ClipJobEntity> findByAssetId(String assetId);
|
||||||
|
|
||||||
|
@Query("""
|
||||||
|
select count(j)
|
||||||
|
from ClipJobEntity j
|
||||||
|
where j.tenantId = :tenantId
|
||||||
|
and j.status not in ('SUCCEEDED', 'FAILED', 'CANCELLED')
|
||||||
|
""")
|
||||||
|
long countActiveByTenantId(String tenantId);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,10 +18,13 @@ video-clipping.s3.bucket=video-clipping-dev
|
||||||
video-clipping.s3.path-style=true
|
video-clipping.s3.path-style=true
|
||||||
video-clipping.database-queue.poll-interval-ms=1000
|
video-clipping.database-queue.poll-interval-ms=1000
|
||||||
video-clipping.database-queue.batch-size=5
|
video-clipping.database-queue.batch-size=5
|
||||||
|
video-clipping.database-queue.max-attempts=3
|
||||||
|
video-clipping.database-queue.retry-backoff-ms=5000
|
||||||
video-clipping.ffmpeg.ffmpeg-binary=ffmpeg
|
video-clipping.ffmpeg.ffmpeg-binary=ffmpeg
|
||||||
video-clipping.ffmpeg.input-directory=./tmp/ffmpeg-input
|
video-clipping.ffmpeg.input-directory=./tmp/ffmpeg-input
|
||||||
video-clipping.ffmpeg.output-directory=./tmp/ffmpeg-output
|
video-clipping.ffmpeg.output-directory=./tmp/ffmpeg-output
|
||||||
video-clipping.ffmpeg.cleanup-local-files=true
|
video-clipping.ffmpeg.cleanup-local-files=true
|
||||||
|
video-clipping.quotas.max-active-jobs-per-tenant=5
|
||||||
video-clipping.cleanup.enabled=true
|
video-clipping.cleanup.enabled=true
|
||||||
video-clipping.cleanup.local-artifact-poll-interval-ms=300000
|
video-clipping.cleanup.local-artifact-poll-interval-ms=300000
|
||||||
video-clipping.cleanup.local-artifact-retention-hours=24
|
video-clipping.cleanup.local-artifact-retention-hours=24
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
alter table video_assets
|
||||||
|
add column if not exists tenant_id varchar(64) not null default 'default';
|
||||||
|
|
||||||
|
alter table clip_jobs
|
||||||
|
add column if not exists tenant_id varchar(64) not null default 'default';
|
||||||
|
|
||||||
|
create index if not exists idx_video_assets_tenant_id on video_assets(tenant_id);
|
||||||
|
create index if not exists idx_clip_jobs_tenant_id_status on clip_jobs(tenant_id, status);
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
package org.example.videoclips.api;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
|
@SpringBootTest(properties = "video-clipping.quotas.max-active-jobs-per-tenant=0")
|
||||||
|
@AutoConfigureMockMvc
|
||||||
|
class TenantQuotaControllerTest {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private MockMvc mockMvc;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsClipJobCreationWhenTenantActiveJobQuotaIsExceeded() throws Exception {
|
||||||
|
String assetResponse = mockMvc.perform(post("/v1/video-assets")
|
||||||
|
.header("X-Tenant-Id", "tenant-a")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("""
|
||||||
|
{
|
||||||
|
"fileName": "quota.mp4",
|
||||||
|
"contentType": "video/mp4",
|
||||||
|
"contentLengthBytes": 4096,
|
||||||
|
"checksumSha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||||
|
}
|
||||||
|
"""))
|
||||||
|
.andExpect(status().isCreated())
|
||||||
|
.andReturn()
|
||||||
|
.getResponse()
|
||||||
|
.getContentAsString();
|
||||||
|
|
||||||
|
String assetId = JsonTestHelper.read(assetResponse, "$.assetId");
|
||||||
|
String uploadId = JsonTestHelper.read(assetResponse, "$.upload.uploadId");
|
||||||
|
|
||||||
|
mockMvc.perform(post("/v1/video-assets/{assetId}/uploads:complete", assetId)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("""
|
||||||
|
{
|
||||||
|
"uploadId": "%s",
|
||||||
|
"sourceDurationSeconds": 17,
|
||||||
|
"parts": [
|
||||||
|
{
|
||||||
|
"partNumber": 1,
|
||||||
|
"etag": "\\"etag-1\\""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""".formatted(uploadId)))
|
||||||
|
.andExpect(status().isOk());
|
||||||
|
|
||||||
|
mockMvc.perform(post("/v1/video-assets/{assetId}/clip-jobs", assetId)
|
||||||
|
.header("X-Tenant-Id", "tenant-a")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("""
|
||||||
|
{
|
||||||
|
"segmentDurationSeconds": 8,
|
||||||
|
"accuracyMode": "FAST",
|
||||||
|
"outputContainer": "mp4",
|
||||||
|
"overwriteExisting": false
|
||||||
|
}
|
||||||
|
"""))
|
||||||
|
.andExpect(status().isTooManyRequests())
|
||||||
|
.andExpect(jsonPath("$.type").value("https://api.example.com/problems/quota-exceeded"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -41,6 +41,7 @@ class RetentionCleanupJobTest {
|
||||||
);
|
);
|
||||||
VideoAsset asset = new VideoAsset(
|
VideoAsset asset = new VideoAsset(
|
||||||
"asset_1",
|
"asset_1",
|
||||||
|
"default",
|
||||||
"source.mp4",
|
"source.mp4",
|
||||||
"video/mp4",
|
"video/mp4",
|
||||||
4096,
|
4096,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
stub-clip-content-clip_3db7ae28dd6e
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
stub-clip-content-clip_a87a659135b7
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
stub-clip-content-clip_f6d9f025b976
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
stub-clip-content-clip_10c8dc32bebf
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
stub-clip-content-clip_04750c742dae
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
stub-clip-content-clip_9c0062d6e046
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
stub-clip-content-clip_6610d92757c1
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
stub-clip-content-clip_f6612e1119d5
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
stub-clip-content-clip_b68e31fe0da6
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
stub-clip-content-clip_5806cd2fc91e
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
stub-clip-content-clip_297c6a9ab956
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
stub-clip-content-clip_8a000ebfe018
|
||||||
Loading…
Reference in New Issue