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:
JSLMPR 2026-07-09 01:07:07 +02:00
parent 8548542248
commit 1bd879f1e2
31 changed files with 215 additions and 17 deletions

View File

@ -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.

View File

@ -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<Object> handleMethodArgumentNotValid(
MethodArgumentNotValidException ex,

View File

@ -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}")

View File

@ -0,0 +1,8 @@
package org.example.videoclips.application;
public class QuotaExceededException extends RuntimeException {
public QuotaExceededException(String message) {
super(message);
}
}

View File

@ -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<String, Object> createAsset(CreateVideoAssetRequest request, String idempotencyKey) {
public Map<String, Object> 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<String, Object> 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<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);
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<String, Object> 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()));
}

View File

@ -44,6 +44,8 @@ public interface VideoClippingRepository {
boolean hasActiveJobForAsset(String assetId);
int countActiveJobsForTenant(String tenantId);
Map<String, Object> findAssetResponseByIdempotencyKey(String key);
void storeAssetResponseByIdempotencyKey(String key, Map<String, Object> response);

View File

@ -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;
}
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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<String, Object> findAssetResponseByIdempotencyKey(String key) {
return assetResponsesByIdempotencyKey.get(key);

View File

@ -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(),

View File

@ -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<String, Object> findAssetResponseByIdempotencyKey(String key) {
return assetIdempotencyJpaRepository.findById(key)

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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<ClipJobEntity, String> {
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);
}

View File

@ -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

View File

@ -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);

View File

@ -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"));
}
}

View File

@ -41,6 +41,7 @@ class RetentionCleanupJobTest {
);
VideoAsset asset = new VideoAsset(
"asset_1",
"default",
"source.mp4",
"video/mp4",
4096,

View File

@ -0,0 +1 @@
stub-clip-content-clip_3db7ae28dd6e

View File

@ -0,0 +1 @@
stub-clip-content-clip_a87a659135b7

View File

@ -0,0 +1 @@
stub-clip-content-clip_f6d9f025b976

View File

@ -0,0 +1 @@
stub-clip-content-clip_10c8dc32bebf

View File

@ -0,0 +1 @@
stub-clip-content-clip_04750c742dae

View File

@ -0,0 +1 @@
stub-clip-content-clip_9c0062d6e046

View File

@ -0,0 +1 @@
stub-clip-content-clip_6610d92757c1

View File

@ -0,0 +1 @@
stub-clip-content-clip_f6612e1119d5

View File

@ -0,0 +1 @@
stub-clip-content-clip_b68e31fe0da6

View File

@ -0,0 +1 @@
stub-clip-content-clip_5806cd2fc91e

View File

@ -0,0 +1 @@
stub-clip-content-clip_297c6a9ab956

View File

@ -0,0 +1 @@
stub-clip-content-clip_8a000ebfe018