From 1bd879f1e2e4fd33321746bff5a2278bbfd8e788 Mon Sep 17 00:00:00 2001 From: JSLMPR Date: Thu, 9 Jul 2026 01:07:07 +0200 Subject: [PATCH] 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. --- ...eo-clipping-service-implementation-plan.md | 2 +- .../videoclips/api/ApiExceptionHandler.java | 6 ++ .../videoclips/api/VideoAssetController.java | 10 +-- .../application/QuotaExceededException.java | 8 +++ .../application/VideoAssetService.java | 21 ++++-- .../application/VideoClippingRepository.java | 2 + .../config/VideoClippingProperties.java | 19 +++++ .../example/videoclips/domain/ClipJob.java | 11 +-- .../example/videoclips/domain/VideoAsset.java | 7 +- .../InMemoryVideoAssetRepository.java | 10 +++ .../persistence/JpaVideoClippingMapper.java | 4 ++ .../JpaVideoClippingRepository.java | 5 ++ .../persistence/entity/ClipJobEntity.java | 11 +++ .../persistence/entity/VideoAssetEntity.java | 11 +++ .../spring/ClipJobJpaRepository.java | 9 +++ src/main/resources/application.properties | 3 + .../db/migration/V6__tenant_quota_support.sql | 8 +++ .../api/TenantQuotaControllerTest.java | 72 +++++++++++++++++++ .../operations/RetentionCleanupJobTest.java | 1 + .../clips/job_30a3a3959622/0.mp4 | 1 + .../clips/job_30a3a3959622/1.mp4 | 1 + .../clips/job_30a3a3959622/2.mp4 | 1 + .../clips/job_92b86e138cb9/0.mp4 | 1 + .../clips/job_92b86e138cb9/1.mp4 | 1 + .../clips/job_92b86e138cb9/2.mp4 | 1 + .../clips/job_c8c81ec6507f/0.mp4 | 1 + .../clips/job_c8c81ec6507f/1.mp4 | 1 + .../clips/job_c8c81ec6507f/2.mp4 | 1 + .../clips/job_e0204b61aab7/0.mp4 | 1 + .../clips/job_e0204b61aab7/1.mp4 | 1 + .../clips/job_e0204b61aab7/2.mp4 | 1 + 31 files changed, 215 insertions(+), 17 deletions(-) create mode 100644 src/main/java/org/example/videoclips/application/QuotaExceededException.java create mode 100644 src/main/resources/db/migration/V6__tenant_quota_support.sql create mode 100644 src/test/java/org/example/videoclips/api/TenantQuotaControllerTest.java create mode 100644 tmp/in-memory-storage/clips/job_30a3a3959622/0.mp4 create mode 100644 tmp/in-memory-storage/clips/job_30a3a3959622/1.mp4 create mode 100644 tmp/in-memory-storage/clips/job_30a3a3959622/2.mp4 create mode 100644 tmp/in-memory-storage/clips/job_92b86e138cb9/0.mp4 create mode 100644 tmp/in-memory-storage/clips/job_92b86e138cb9/1.mp4 create mode 100644 tmp/in-memory-storage/clips/job_92b86e138cb9/2.mp4 create mode 100644 tmp/in-memory-storage/clips/job_c8c81ec6507f/0.mp4 create mode 100644 tmp/in-memory-storage/clips/job_c8c81ec6507f/1.mp4 create mode 100644 tmp/in-memory-storage/clips/job_c8c81ec6507f/2.mp4 create mode 100644 tmp/in-memory-storage/clips/job_e0204b61aab7/0.mp4 create mode 100644 tmp/in-memory-storage/clips/job_e0204b61aab7/1.mp4 create mode 100644 tmp/in-memory-storage/clips/job_e0204b61aab7/2.mp4 diff --git a/docs/video-clipping-service-implementation-plan.md b/docs/video-clipping-service-implementation-plan.md index ded3cd0..c777fd5 100644 --- a/docs/video-clipping-service-implementation-plan.md +++ b/docs/video-clipping-service-implementation-plan.md @@ -703,7 +703,7 @@ Keep the domain independent from Spring framework details. Adapters implement st 1. [x] `EXACT` clipping mode. 2. [x] Idempotency keys. 3. [~] Retry and DLQ handling. -4. [ ] Tenant quotas. +4. [x] Tenant quotas. 5. [~] Full `application/problem+json` error model. 6. [x] Cleanup lifecycle jobs. 7. [~] Security review. diff --git a/src/main/java/org/example/videoclips/api/ApiExceptionHandler.java b/src/main/java/org/example/videoclips/api/ApiExceptionHandler.java index c03c2c5..9f24e76 100644 --- a/src/main/java/org/example/videoclips/api/ApiExceptionHandler.java +++ b/src/main/java/org/example/videoclips/api/ApiExceptionHandler.java @@ -4,6 +4,7 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.ConstraintViolationException; import org.example.videoclips.application.NotFoundException; import org.example.videoclips.application.PayloadTooLargeException; +import org.example.videoclips.application.QuotaExceededException; import org.example.videoclips.application.StateConflictException; import org.example.videoclips.application.UnsupportedMediaTypeException; 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); } + @ExceptionHandler(QuotaExceededException.class) + ProblemDetail handleQuotaExceeded(QuotaExceededException ex, HttpServletRequest request) { + return problem(HttpStatus.TOO_MANY_REQUESTS, "quota-exceeded", ex.getMessage(), request.getRequestURI(), null); + } + @Override protected ResponseEntity handleMethodArgumentNotValid( MethodArgumentNotValidException ex, diff --git a/src/main/java/org/example/videoclips/api/VideoAssetController.java b/src/main/java/org/example/videoclips/api/VideoAssetController.java index c649060..da30ab7 100644 --- a/src/main/java/org/example/videoclips/api/VideoAssetController.java +++ b/src/main/java/org/example/videoclips/api/VideoAssetController.java @@ -30,9 +30,10 @@ public class VideoAssetController { @ResponseStatus(HttpStatus.CREATED) public Object createAsset( @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") @@ -65,9 +66,10 @@ public class VideoAssetController { public Object createClipJob( @PathVariable String assetId, @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}") diff --git a/src/main/java/org/example/videoclips/application/QuotaExceededException.java b/src/main/java/org/example/videoclips/application/QuotaExceededException.java new file mode 100644 index 0000000..df8cc1a --- /dev/null +++ b/src/main/java/org/example/videoclips/application/QuotaExceededException.java @@ -0,0 +1,8 @@ +package org.example.videoclips.application; + +public class QuotaExceededException extends RuntimeException { + + public QuotaExceededException(String message) { + super(message); + } +} diff --git a/src/main/java/org/example/videoclips/application/VideoAssetService.java b/src/main/java/org/example/videoclips/application/VideoAssetService.java index 22a8e7d..7202389 100644 --- a/src/main/java/org/example/videoclips/application/VideoAssetService.java +++ b/src/main/java/org/example/videoclips/application/VideoAssetService.java @@ -26,6 +26,7 @@ import java.util.UUID; @Service public class VideoAssetService { + private static final String DEFAULT_TENANT_ID = "default"; private final VideoClippingRepository repository; private final ObjectStoragePort objectStoragePort; @@ -44,7 +45,7 @@ public class VideoAssetService { this.properties = properties; } - public Map createAsset(CreateVideoAssetRequest request, String idempotencyKey) { + public Map createAsset(CreateVideoAssetRequest request, String idempotencyKey, String tenantId) { if (request.contentLengthBytes() > properties.getMaxFileSizeBytes()) { throw new PayloadTooLargeException("contentLengthBytes exceeds configured max file size"); } @@ -52,7 +53,8 @@ public class VideoAssetService { 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 existing = repository.findAssetResponseByIdempotencyKey(dedupeKey); if (existing != null) { return existing; @@ -65,6 +67,7 @@ public class VideoAssetService { VideoAsset asset = new VideoAsset( assetId, + normalizedTenantId, request.fileName(), request.contentType(), request.contentLengthBytes(), @@ -159,7 +162,7 @@ public class VideoAssetService { ); } - public Map createClipJob(String assetId, ClipJobRequest request, String idempotencyKey) { + public Map createClipJob(String assetId, ClipJobRequest request, String idempotencyKey, String tenantId) { VideoAsset asset = getAsset(assetId); 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"); @@ -168,13 +171,18 @@ public class VideoAssetService { 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 existing = repository.findJobResponseByIdempotencyKey(dedupeKey); if (existing != null) { return existing; } 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)) { throw new StateConflictException("an active clip job already exists for this asset"); } @@ -182,6 +190,7 @@ public class VideoAssetService { ClipJob job = new ClipJob( nextId("job"), assetId, + effectiveTenantId, ClipJobStatus.QUEUED, request.accuracyMode() == null ? AccuracyMode.FAST : request.accuracyMode(), request.segmentDurationSeconds(), @@ -433,6 +442,10 @@ public class VideoAssetService { 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) { repository.saveJobEvent(new JobEvent(nextId("evt"), aggregateId, eventType, message, Instant.now())); } diff --git a/src/main/java/org/example/videoclips/application/VideoClippingRepository.java b/src/main/java/org/example/videoclips/application/VideoClippingRepository.java index 0b8fe98..2bb91d1 100644 --- a/src/main/java/org/example/videoclips/application/VideoClippingRepository.java +++ b/src/main/java/org/example/videoclips/application/VideoClippingRepository.java @@ -44,6 +44,8 @@ public interface VideoClippingRepository { boolean hasActiveJobForAsset(String assetId); + int countActiveJobsForTenant(String tenantId); + Map findAssetResponseByIdempotencyKey(String key); void storeAssetResponseByIdempotencyKey(String key, Map response); diff --git a/src/main/java/org/example/videoclips/config/VideoClippingProperties.java b/src/main/java/org/example/videoclips/config/VideoClippingProperties.java index d066b5f..ee9909a 100644 --- a/src/main/java/org/example/videoclips/config/VideoClippingProperties.java +++ b/src/main/java/org/example/videoclips/config/VideoClippingProperties.java @@ -38,6 +38,8 @@ public class VideoClippingProperties { private final Cleanup cleanup = new Cleanup(); + private final Quotas quotas = new Quotas(); + public long getMaxFileSizeBytes() { return maxFileSizeBytes; } @@ -118,6 +120,10 @@ public class VideoClippingProperties { return cleanup; } + public Quotas getQuotas() { + return quotas; + } + public static class S3 { private String region = "us-east-1"; private String bucket = "video-clipping-dev"; @@ -308,4 +314,17 @@ public class VideoClippingProperties { 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; + } + } } diff --git a/src/main/java/org/example/videoclips/domain/ClipJob.java b/src/main/java/org/example/videoclips/domain/ClipJob.java index 2532631..3a39513 100644 --- a/src/main/java/org/example/videoclips/domain/ClipJob.java +++ b/src/main/java/org/example/videoclips/domain/ClipJob.java @@ -5,6 +5,7 @@ import java.time.Instant; public record ClipJob( String id, String assetId, + String tenantId, ClipJobStatus status, AccuracyMode accuracyMode, int segmentDurationSeconds, @@ -16,27 +17,27 @@ public record ClipJob( String errorMessage ) { 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); } 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); } 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); } 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); } 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); } } diff --git a/src/main/java/org/example/videoclips/domain/VideoAsset.java b/src/main/java/org/example/videoclips/domain/VideoAsset.java index dc6b7ad..55f6400 100644 --- a/src/main/java/org/example/videoclips/domain/VideoAsset.java +++ b/src/main/java/org/example/videoclips/domain/VideoAsset.java @@ -4,6 +4,7 @@ import java.time.Instant; public record VideoAsset( String id, + String tenantId, String fileName, String contentType, long contentLengthBytes, @@ -26,12 +27,12 @@ public record VideoAsset( } 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); } 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); } @@ -40,7 +41,7 @@ public record VideoAsset( } 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); } } diff --git a/src/main/java/org/example/videoclips/infrastructure/InMemoryVideoAssetRepository.java b/src/main/java/org/example/videoclips/infrastructure/InMemoryVideoAssetRepository.java index b275391..e998b26 100644 --- a/src/main/java/org/example/videoclips/infrastructure/InMemoryVideoAssetRepository.java +++ b/src/main/java/org/example/videoclips/infrastructure/InMemoryVideoAssetRepository.java @@ -142,6 +142,16 @@ public class InMemoryVideoAssetRepository implements VideoClippingRepository { && 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 public Map findAssetResponseByIdempotencyKey(String key) { return assetResponsesByIdempotencyKey.get(key); diff --git a/src/main/java/org/example/videoclips/persistence/JpaVideoClippingMapper.java b/src/main/java/org/example/videoclips/persistence/JpaVideoClippingMapper.java index 247dd68..43a0039 100644 --- a/src/main/java/org/example/videoclips/persistence/JpaVideoClippingMapper.java +++ b/src/main/java/org/example/videoclips/persistence/JpaVideoClippingMapper.java @@ -20,6 +20,7 @@ public class JpaVideoClippingMapper { public VideoAssetEntity toEntity(VideoAsset asset) { VideoAssetEntity entity = new VideoAssetEntity(); entity.setId(asset.id()); + entity.setTenantId(asset.tenantId()); entity.setFileName(asset.fileName()); entity.setContentType(asset.contentType()); entity.setContentLengthBytes(asset.contentLengthBytes()); @@ -38,6 +39,7 @@ public class JpaVideoClippingMapper { public VideoAsset toDomain(VideoAssetEntity entity) { return new VideoAsset( entity.getId(), + entity.getTenantId(), entity.getFileName(), entity.getContentType(), entity.getContentLengthBytes(), @@ -83,6 +85,7 @@ public class JpaVideoClippingMapper { ClipJobEntity entity = new ClipJobEntity(); entity.setId(job.id()); entity.setAssetId(job.assetId()); + entity.setTenantId(job.tenantId()); entity.setStatus(job.status().name()); entity.setAccuracyMode(job.accuracyMode().name()); entity.setSegmentDurationSeconds(job.segmentDurationSeconds()); @@ -99,6 +102,7 @@ public class JpaVideoClippingMapper { return new ClipJob( entity.getId(), entity.getAssetId(), + entity.getTenantId(), ClipJobStatus.valueOf(entity.getStatus()), AccuracyMode.valueOf(entity.getAccuracyMode()), entity.getSegmentDurationSeconds(), diff --git a/src/main/java/org/example/videoclips/persistence/JpaVideoClippingRepository.java b/src/main/java/org/example/videoclips/persistence/JpaVideoClippingRepository.java index 9f2fb11..c3db63d 100644 --- a/src/main/java/org/example/videoclips/persistence/JpaVideoClippingRepository.java +++ b/src/main/java/org/example/videoclips/persistence/JpaVideoClippingRepository.java @@ -152,6 +152,11 @@ public class JpaVideoClippingRepository implements VideoClippingRepository { && job.status() != ClipJobStatus.CANCELLED); } + @Override + public int countActiveJobsForTenant(String tenantId) { + return Math.toIntExact(clipJobJpaRepository.countActiveByTenantId(tenantId)); + } + @Override public Map findAssetResponseByIdempotencyKey(String key) { return assetIdempotencyJpaRepository.findById(key) diff --git a/src/main/java/org/example/videoclips/persistence/entity/ClipJobEntity.java b/src/main/java/org/example/videoclips/persistence/entity/ClipJobEntity.java index 7b47b24..1c77394 100644 --- a/src/main/java/org/example/videoclips/persistence/entity/ClipJobEntity.java +++ b/src/main/java/org/example/videoclips/persistence/entity/ClipJobEntity.java @@ -17,6 +17,9 @@ public class ClipJobEntity { @Column(nullable = false, length = 64) private String assetId; + @Column(nullable = false, length = 64) + private String tenantId; + @Column(nullable = false, length = 32) private String status; @@ -62,6 +65,14 @@ public class ClipJobEntity { return status; } + public String getTenantId() { + return tenantId; + } + + public void setTenantId(String tenantId) { + this.tenantId = tenantId; + } + public void setStatus(String status) { this.status = status; } diff --git a/src/main/java/org/example/videoclips/persistence/entity/VideoAssetEntity.java b/src/main/java/org/example/videoclips/persistence/entity/VideoAssetEntity.java index 9d9a3d6..f800941 100644 --- a/src/main/java/org/example/videoclips/persistence/entity/VideoAssetEntity.java +++ b/src/main/java/org/example/videoclips/persistence/entity/VideoAssetEntity.java @@ -14,6 +14,9 @@ public class VideoAssetEntity { @Id private String id; + @Column(nullable = false, length = 64) + private String tenantId; + @Column(nullable = false, length = 255) private String fileName; @@ -59,6 +62,14 @@ public class VideoAssetEntity { return fileName; } + public String getTenantId() { + return tenantId; + } + + public void setTenantId(String tenantId) { + this.tenantId = tenantId; + } + public void setFileName(String fileName) { this.fileName = fileName; } diff --git a/src/main/java/org/example/videoclips/persistence/spring/ClipJobJpaRepository.java b/src/main/java/org/example/videoclips/persistence/spring/ClipJobJpaRepository.java index 509f96d..4e84b1e 100644 --- a/src/main/java/org/example/videoclips/persistence/spring/ClipJobJpaRepository.java +++ b/src/main/java/org/example/videoclips/persistence/spring/ClipJobJpaRepository.java @@ -2,10 +2,19 @@ package org.example.videoclips.persistence.spring; import org.example.videoclips.persistence.entity.ClipJobEntity; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; import java.util.List; public interface ClipJobJpaRepository extends JpaRepository { List 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); } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 8634d02..ee5bb83 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -18,10 +18,13 @@ video-clipping.s3.bucket=video-clipping-dev video-clipping.s3.path-style=true video-clipping.database-queue.poll-interval-ms=1000 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.input-directory=./tmp/ffmpeg-input video-clipping.ffmpeg.output-directory=./tmp/ffmpeg-output video-clipping.ffmpeg.cleanup-local-files=true +video-clipping.quotas.max-active-jobs-per-tenant=5 video-clipping.cleanup.enabled=true video-clipping.cleanup.local-artifact-poll-interval-ms=300000 video-clipping.cleanup.local-artifact-retention-hours=24 diff --git a/src/main/resources/db/migration/V6__tenant_quota_support.sql b/src/main/resources/db/migration/V6__tenant_quota_support.sql new file mode 100644 index 0000000..19c0f4e --- /dev/null +++ b/src/main/resources/db/migration/V6__tenant_quota_support.sql @@ -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); diff --git a/src/test/java/org/example/videoclips/api/TenantQuotaControllerTest.java b/src/test/java/org/example/videoclips/api/TenantQuotaControllerTest.java new file mode 100644 index 0000000..2b0f642 --- /dev/null +++ b/src/test/java/org/example/videoclips/api/TenantQuotaControllerTest.java @@ -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")); + } +} diff --git a/src/test/java/org/example/videoclips/operations/RetentionCleanupJobTest.java b/src/test/java/org/example/videoclips/operations/RetentionCleanupJobTest.java index e394832..941e335 100644 --- a/src/test/java/org/example/videoclips/operations/RetentionCleanupJobTest.java +++ b/src/test/java/org/example/videoclips/operations/RetentionCleanupJobTest.java @@ -41,6 +41,7 @@ class RetentionCleanupJobTest { ); VideoAsset asset = new VideoAsset( "asset_1", + "default", "source.mp4", "video/mp4", 4096, diff --git a/tmp/in-memory-storage/clips/job_30a3a3959622/0.mp4 b/tmp/in-memory-storage/clips/job_30a3a3959622/0.mp4 new file mode 100644 index 0000000..ab74bad --- /dev/null +++ b/tmp/in-memory-storage/clips/job_30a3a3959622/0.mp4 @@ -0,0 +1 @@ +stub-clip-content-clip_3db7ae28dd6e \ No newline at end of file diff --git a/tmp/in-memory-storage/clips/job_30a3a3959622/1.mp4 b/tmp/in-memory-storage/clips/job_30a3a3959622/1.mp4 new file mode 100644 index 0000000..5fa6d13 --- /dev/null +++ b/tmp/in-memory-storage/clips/job_30a3a3959622/1.mp4 @@ -0,0 +1 @@ +stub-clip-content-clip_a87a659135b7 \ No newline at end of file diff --git a/tmp/in-memory-storage/clips/job_30a3a3959622/2.mp4 b/tmp/in-memory-storage/clips/job_30a3a3959622/2.mp4 new file mode 100644 index 0000000..0c8fbd2 --- /dev/null +++ b/tmp/in-memory-storage/clips/job_30a3a3959622/2.mp4 @@ -0,0 +1 @@ +stub-clip-content-clip_f6d9f025b976 \ No newline at end of file diff --git a/tmp/in-memory-storage/clips/job_92b86e138cb9/0.mp4 b/tmp/in-memory-storage/clips/job_92b86e138cb9/0.mp4 new file mode 100644 index 0000000..6b36211 --- /dev/null +++ b/tmp/in-memory-storage/clips/job_92b86e138cb9/0.mp4 @@ -0,0 +1 @@ +stub-clip-content-clip_10c8dc32bebf \ No newline at end of file diff --git a/tmp/in-memory-storage/clips/job_92b86e138cb9/1.mp4 b/tmp/in-memory-storage/clips/job_92b86e138cb9/1.mp4 new file mode 100644 index 0000000..8dcb3f8 --- /dev/null +++ b/tmp/in-memory-storage/clips/job_92b86e138cb9/1.mp4 @@ -0,0 +1 @@ +stub-clip-content-clip_04750c742dae \ No newline at end of file diff --git a/tmp/in-memory-storage/clips/job_92b86e138cb9/2.mp4 b/tmp/in-memory-storage/clips/job_92b86e138cb9/2.mp4 new file mode 100644 index 0000000..c139735 --- /dev/null +++ b/tmp/in-memory-storage/clips/job_92b86e138cb9/2.mp4 @@ -0,0 +1 @@ +stub-clip-content-clip_9c0062d6e046 \ No newline at end of file diff --git a/tmp/in-memory-storage/clips/job_c8c81ec6507f/0.mp4 b/tmp/in-memory-storage/clips/job_c8c81ec6507f/0.mp4 new file mode 100644 index 0000000..314534f --- /dev/null +++ b/tmp/in-memory-storage/clips/job_c8c81ec6507f/0.mp4 @@ -0,0 +1 @@ +stub-clip-content-clip_6610d92757c1 \ No newline at end of file diff --git a/tmp/in-memory-storage/clips/job_c8c81ec6507f/1.mp4 b/tmp/in-memory-storage/clips/job_c8c81ec6507f/1.mp4 new file mode 100644 index 0000000..22b25d7 --- /dev/null +++ b/tmp/in-memory-storage/clips/job_c8c81ec6507f/1.mp4 @@ -0,0 +1 @@ +stub-clip-content-clip_f6612e1119d5 \ No newline at end of file diff --git a/tmp/in-memory-storage/clips/job_c8c81ec6507f/2.mp4 b/tmp/in-memory-storage/clips/job_c8c81ec6507f/2.mp4 new file mode 100644 index 0000000..9c0d3cc --- /dev/null +++ b/tmp/in-memory-storage/clips/job_c8c81ec6507f/2.mp4 @@ -0,0 +1 @@ +stub-clip-content-clip_b68e31fe0da6 \ No newline at end of file diff --git a/tmp/in-memory-storage/clips/job_e0204b61aab7/0.mp4 b/tmp/in-memory-storage/clips/job_e0204b61aab7/0.mp4 new file mode 100644 index 0000000..37dc36c --- /dev/null +++ b/tmp/in-memory-storage/clips/job_e0204b61aab7/0.mp4 @@ -0,0 +1 @@ +stub-clip-content-clip_5806cd2fc91e \ No newline at end of file diff --git a/tmp/in-memory-storage/clips/job_e0204b61aab7/1.mp4 b/tmp/in-memory-storage/clips/job_e0204b61aab7/1.mp4 new file mode 100644 index 0000000..c560785 --- /dev/null +++ b/tmp/in-memory-storage/clips/job_e0204b61aab7/1.mp4 @@ -0,0 +1 @@ +stub-clip-content-clip_297c6a9ab956 \ No newline at end of file diff --git a/tmp/in-memory-storage/clips/job_e0204b61aab7/2.mp4 b/tmp/in-memory-storage/clips/job_e0204b61aab7/2.mp4 new file mode 100644 index 0000000..4bfb8c6 --- /dev/null +++ b/tmp/in-memory-storage/clips/job_e0204b61aab7/2.mp4 @@ -0,0 +1 @@ +stub-clip-content-clip_8a000ebfe018 \ No newline at end of file