diff --git a/src/main/java/org/example/videoclips/application/VideoAssetService.java b/src/main/java/org/example/videoclips/application/VideoAssetService.java index 8e1012d..a2d7888 100644 --- a/src/main/java/org/example/videoclips/application/VideoAssetService.java +++ b/src/main/java/org/example/videoclips/application/VideoAssetService.java @@ -75,7 +75,8 @@ public class VideoAssetService { uploadId, null, now, - null + null, + now.plus(properties.getCleanup().getSourceRetentionHours(), ChronoUnit.HOURS) ); repository.saveAsset(asset); diff --git a/src/main/java/org/example/videoclips/application/VideoClippingRepository.java b/src/main/java/org/example/videoclips/application/VideoClippingRepository.java index eb0c91b..798a486 100644 --- a/src/main/java/org/example/videoclips/application/VideoClippingRepository.java +++ b/src/main/java/org/example/videoclips/application/VideoClippingRepository.java @@ -6,6 +6,7 @@ import org.example.videoclips.domain.JobEvent; import org.example.videoclips.domain.UploadSession; import org.example.videoclips.domain.VideoAsset; +import java.time.Instant; import java.util.List; import java.util.Map; @@ -31,6 +32,10 @@ public interface VideoClippingRepository { Clip findClipById(String clipId); + List findExpiredAssets(Instant cutoff); + + List findExpiredClips(Instant cutoff); + void saveJobEvent(JobEvent event); List findEventsByAggregateId(String aggregateId); diff --git a/src/main/java/org/example/videoclips/config/VideoClippingProperties.java b/src/main/java/org/example/videoclips/config/VideoClippingProperties.java index d42b4d4..48c18d0 100644 --- a/src/main/java/org/example/videoclips/config/VideoClippingProperties.java +++ b/src/main/java/org/example/videoclips/config/VideoClippingProperties.java @@ -229,6 +229,15 @@ public class VideoClippingProperties { @Min(1) private int localArtifactRetentionHours = 24; + @Min(30000) + private long retentionPollIntervalMs = 3600000; + + @Min(1) + private int sourceRetentionHours = 168; + + @Min(1) + private int clipRetentionHours = 168; + public boolean isEnabled() { return enabled; } @@ -252,5 +261,29 @@ public class VideoClippingProperties { public void setLocalArtifactRetentionHours(int localArtifactRetentionHours) { this.localArtifactRetentionHours = localArtifactRetentionHours; } + + public long getRetentionPollIntervalMs() { + return retentionPollIntervalMs; + } + + public void setRetentionPollIntervalMs(long retentionPollIntervalMs) { + this.retentionPollIntervalMs = retentionPollIntervalMs; + } + + public int getSourceRetentionHours() { + return sourceRetentionHours; + } + + public void setSourceRetentionHours(int sourceRetentionHours) { + this.sourceRetentionHours = sourceRetentionHours; + } + + public int getClipRetentionHours() { + return clipRetentionHours; + } + + public void setClipRetentionHours(int clipRetentionHours) { + this.clipRetentionHours = clipRetentionHours; + } } } diff --git a/src/main/java/org/example/videoclips/domain/Clip.java b/src/main/java/org/example/videoclips/domain/Clip.java index 42eead2..91fcc0e 100644 --- a/src/main/java/org/example/videoclips/domain/Clip.java +++ b/src/main/java/org/example/videoclips/domain/Clip.java @@ -12,6 +12,7 @@ public record Clip( long durationSeconds, String contentType, long contentLengthBytes, - Instant createdAt + Instant createdAt, + Instant expiresAt ) { } diff --git a/src/main/java/org/example/videoclips/domain/VideoAsset.java b/src/main/java/org/example/videoclips/domain/VideoAsset.java index 3243059..dc6b7ad 100644 --- a/src/main/java/org/example/videoclips/domain/VideoAsset.java +++ b/src/main/java/org/example/videoclips/domain/VideoAsset.java @@ -14,7 +14,8 @@ public record VideoAsset( String uploadId, Long sourceDurationSeconds, Instant createdAt, - Instant uploadedAt + Instant uploadedAt, + Instant expiresAt ) { public enum Status { PENDING_UPLOAD, @@ -26,15 +27,20 @@ public record VideoAsset( public VideoAsset withUploadCompleted(long durationSeconds, Instant uploadedAt) { return new VideoAsset(id, fileName, contentType, contentLengthBytes, checksumSha256, clipProfile, sourceObjectKey, - Status.UPLOADED, uploadId, durationSeconds, createdAt, uploadedAt); + Status.UPLOADED, uploadId, durationSeconds, createdAt, uploadedAt, expiresAt); } public VideoAsset withStatus(Status status) { return new VideoAsset(id, fileName, contentType, contentLengthBytes, checksumSha256, clipProfile, sourceObjectKey, - status, uploadId, sourceDurationSeconds, createdAt, uploadedAt); + status, uploadId, sourceDurationSeconds, createdAt, uploadedAt, expiresAt); } public VideoAsset withDeleted() { return withStatus(Status.DELETED); } + + public VideoAsset withExpiresAt(Instant expiration) { + return new VideoAsset(id, 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 f9604ae..0559fd8 100644 --- a/src/main/java/org/example/videoclips/infrastructure/InMemoryVideoAssetRepository.java +++ b/src/main/java/org/example/videoclips/infrastructure/InMemoryVideoAssetRepository.java @@ -11,6 +11,7 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Repository; import java.util.ArrayList; +import java.time.Instant; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @@ -89,6 +90,28 @@ public class InMemoryVideoAssetRepository implements VideoClippingRepository { return clips.get(clipId); } + @Override + public List findExpiredAssets(Instant cutoff) { + List result = new ArrayList<>(); + for (VideoAsset asset : assets.values()) { + if (asset.expiresAt() != null && asset.expiresAt().isBefore(cutoff) && asset.status() != VideoAsset.Status.DELETED) { + result.add(asset); + } + } + return result; + } + + @Override + public List findExpiredClips(Instant cutoff) { + List result = new ArrayList<>(); + for (Clip clip : clips.values()) { + if (clip.expiresAt() != null && clip.expiresAt().isBefore(cutoff)) { + result.add(clip); + } + } + return result; + } + @Override public void saveJobEvent(JobEvent event) { jobEvents.put(event.id(), event); diff --git a/src/main/java/org/example/videoclips/operations/RetentionCleanupJob.java b/src/main/java/org/example/videoclips/operations/RetentionCleanupJob.java new file mode 100644 index 0000000..532c3fb --- /dev/null +++ b/src/main/java/org/example/videoclips/operations/RetentionCleanupJob.java @@ -0,0 +1,40 @@ +package org.example.videoclips.operations; + +import org.example.videoclips.application.VideoClippingRepository; +import org.example.videoclips.config.VideoClippingProperties; +import org.example.videoclips.domain.Clip; +import org.example.videoclips.domain.VideoAsset; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.Instant; + +@Component +public class RetentionCleanupJob { + + private final VideoClippingRepository repository; + private final VideoClippingProperties properties; + + public RetentionCleanupJob(VideoClippingRepository repository, VideoClippingProperties properties) { + this.repository = repository; + this.properties = properties; + } + + @Scheduled(fixedDelayString = "${video-clipping.cleanup.retention-poll-interval-ms:3600000}") + public void cleanupExpiredRecords() { + if (!properties.getCleanup().isEnabled()) { + return; + } + + Instant now = Instant.now(); + + for (Clip clip : repository.findExpiredClips(now)) { + // Current retention behavior is metadata-only soft expiration for the scaffold. + // Real object storage deletion should hang off the storage port once lifecycle policy is finalized. + } + + for (VideoAsset asset : repository.findExpiredAssets(now)) { + repository.saveAsset(asset.withDeleted()); + } + } +} diff --git a/src/main/java/org/example/videoclips/persistence/JpaVideoClippingMapper.java b/src/main/java/org/example/videoclips/persistence/JpaVideoClippingMapper.java index 7b73fd4..247dd68 100644 --- a/src/main/java/org/example/videoclips/persistence/JpaVideoClippingMapper.java +++ b/src/main/java/org/example/videoclips/persistence/JpaVideoClippingMapper.java @@ -31,6 +31,7 @@ public class JpaVideoClippingMapper { entity.setSourceDurationSeconds(asset.sourceDurationSeconds()); entity.setCreatedAt(asset.createdAt()); entity.setUploadedAt(asset.uploadedAt()); + entity.setExpiresAt(asset.expiresAt()); return entity; } @@ -47,7 +48,8 @@ public class JpaVideoClippingMapper { entity.getUploadId(), entity.getSourceDurationSeconds(), entity.getCreatedAt(), - entity.getUploadedAt() + entity.getUploadedAt(), + entity.getExpiresAt() ); } @@ -121,6 +123,7 @@ public class JpaVideoClippingMapper { entity.setContentType(clip.contentType()); entity.setContentLengthBytes(clip.contentLengthBytes()); entity.setCreatedAt(clip.createdAt()); + entity.setExpiresAt(clip.expiresAt()); return entity; } @@ -135,7 +138,8 @@ public class JpaVideoClippingMapper { entity.getDurationSeconds(), entity.getContentType(), entity.getContentLengthBytes(), - entity.getCreatedAt() + entity.getCreatedAt(), + entity.getExpiresAt() ); } diff --git a/src/main/java/org/example/videoclips/persistence/JpaVideoClippingRepository.java b/src/main/java/org/example/videoclips/persistence/JpaVideoClippingRepository.java index 5916180..0fe3482 100644 --- a/src/main/java/org/example/videoclips/persistence/JpaVideoClippingRepository.java +++ b/src/main/java/org/example/videoclips/persistence/JpaVideoClippingRepository.java @@ -114,6 +114,20 @@ public class JpaVideoClippingRepository implements VideoClippingRepository { return clipJpaRepository.findById(clipId).map(mapper::toDomain).orElse(null); } + @Override + public List findExpiredAssets(java.time.Instant cutoff) { + return videoAssetJpaRepository.findByExpiresAtBeforeAndStatusNot(cutoff, "DELETED").stream() + .map(mapper::toDomain) + .toList(); + } + + @Override + public List findExpiredClips(java.time.Instant cutoff) { + return clipJpaRepository.findByExpiresAtBefore(cutoff).stream() + .map(mapper::toDomain) + .toList(); + } + @Override public void saveJobEvent(JobEvent event) { jobEventJpaRepository.save(mapper.toEntity(event)); diff --git a/src/main/java/org/example/videoclips/persistence/entity/ClipEntity.java b/src/main/java/org/example/videoclips/persistence/entity/ClipEntity.java index 37b1d18..8e816d4 100644 --- a/src/main/java/org/example/videoclips/persistence/entity/ClipEntity.java +++ b/src/main/java/org/example/videoclips/persistence/entity/ClipEntity.java @@ -41,6 +41,8 @@ public class ClipEntity { @Column(nullable = false) private Instant createdAt; + private Instant expiresAt; + public String getId() { return id; } @@ -120,4 +122,12 @@ public class ClipEntity { public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; } + + public Instant getExpiresAt() { + return expiresAt; + } + + public void setExpiresAt(Instant expiresAt) { + this.expiresAt = expiresAt; + } } 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 7cb63b2..9d9a3d6 100644 --- a/src/main/java/org/example/videoclips/persistence/entity/VideoAssetEntity.java +++ b/src/main/java/org/example/videoclips/persistence/entity/VideoAssetEntity.java @@ -45,6 +45,8 @@ public class VideoAssetEntity { private Instant uploadedAt; + private Instant expiresAt; + public String getId() { return id; } @@ -140,4 +142,12 @@ public class VideoAssetEntity { public void setUploadedAt(Instant uploadedAt) { this.uploadedAt = uploadedAt; } + + public Instant getExpiresAt() { + return expiresAt; + } + + public void setExpiresAt(Instant expiresAt) { + this.expiresAt = expiresAt; + } } diff --git a/src/main/java/org/example/videoclips/persistence/spring/ClipJpaRepository.java b/src/main/java/org/example/videoclips/persistence/spring/ClipJpaRepository.java index b72eebc..a7f86a9 100644 --- a/src/main/java/org/example/videoclips/persistence/spring/ClipJpaRepository.java +++ b/src/main/java/org/example/videoclips/persistence/spring/ClipJpaRepository.java @@ -3,9 +3,12 @@ package org.example.videoclips.persistence.spring; import org.example.videoclips.persistence.entity.ClipEntity; import org.springframework.data.jpa.repository.JpaRepository; +import java.time.Instant; import java.util.List; public interface ClipJpaRepository extends JpaRepository { List findByJobId(String jobId); + + List findByExpiresAtBefore(Instant cutoff); } diff --git a/src/main/java/org/example/videoclips/persistence/spring/VideoAssetJpaRepository.java b/src/main/java/org/example/videoclips/persistence/spring/VideoAssetJpaRepository.java index 38d0266..2a190a6 100644 --- a/src/main/java/org/example/videoclips/persistence/spring/VideoAssetJpaRepository.java +++ b/src/main/java/org/example/videoclips/persistence/spring/VideoAssetJpaRepository.java @@ -3,5 +3,10 @@ package org.example.videoclips.persistence.spring; import org.example.videoclips.persistence.entity.VideoAssetEntity; import org.springframework.data.jpa.repository.JpaRepository; +import java.time.Instant; +import java.util.List; + public interface VideoAssetJpaRepository extends JpaRepository { + + List findByExpiresAtBeforeAndStatusNot(Instant cutoff, String status); } diff --git a/src/main/java/org/example/videoclips/processing/FfmpegVideoClipperAdapter.java b/src/main/java/org/example/videoclips/processing/FfmpegVideoClipperAdapter.java index 766f14a..f71c1bd 100644 --- a/src/main/java/org/example/videoclips/processing/FfmpegVideoClipperAdapter.java +++ b/src/main/java/org/example/videoclips/processing/FfmpegVideoClipperAdapter.java @@ -136,7 +136,8 @@ public class FfmpegVideoClipperAdapter implements VideoClipperPort { duration, "video/mp4", duration * bytesPerSecond, - Instant.now() + Instant.now(), + Instant.now().plus(properties.getCleanup().getClipRetentionHours(), java.time.temporal.ChronoUnit.HOURS) ), localPath)); start += segmentDurationSeconds; index++; diff --git a/src/main/java/org/example/videoclips/processing/StubVideoClipperAdapter.java b/src/main/java/org/example/videoclips/processing/StubVideoClipperAdapter.java index dd5ce15..1862710 100644 --- a/src/main/java/org/example/videoclips/processing/StubVideoClipperAdapter.java +++ b/src/main/java/org/example/videoclips/processing/StubVideoClipperAdapter.java @@ -2,6 +2,7 @@ package org.example.videoclips.processing; import org.example.videoclips.domain.AccuracyMode; import org.example.videoclips.domain.Clip; +import org.example.videoclips.config.VideoClippingProperties; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Component; @@ -17,6 +18,12 @@ import java.util.UUID; @ConditionalOnProperty(name = "video-clipping.processing", havingValue = "stub", matchIfMissing = true) public class StubVideoClipperAdapter implements VideoClipperPort { + private final VideoClippingProperties properties; + + public StubVideoClipperAdapter(VideoClippingProperties properties) { + this.properties = properties; + } + @Override public List generateClips( String jobId, @@ -51,7 +58,8 @@ public class StubVideoClipperAdapter implements VideoClipperPort { duration, "video/mp4", duration * bytesPerSecond, - Instant.now() + Instant.now(), + Instant.now().plus(properties.getCleanup().getClipRetentionHours(), java.time.temporal.ChronoUnit.HOURS) ), localPath)); start += segmentDurationSeconds; index++; diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 15e5b32..8634d02 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -25,3 +25,6 @@ video-clipping.ffmpeg.cleanup-local-files=true video-clipping.cleanup.enabled=true video-clipping.cleanup.local-artifact-poll-interval-ms=300000 video-clipping.cleanup.local-artifact-retention-hours=24 +video-clipping.cleanup.retention-poll-interval-ms=3600000 +video-clipping.cleanup.source-retention-hours=168 +video-clipping.cleanup.clip-retention-hours=168 diff --git a/tmp/in-memory-storage/clips/job_13022b79d037/0.mp4 b/tmp/in-memory-storage/clips/job_13022b79d037/0.mp4 new file mode 100644 index 0000000..d20e5c8 --- /dev/null +++ b/tmp/in-memory-storage/clips/job_13022b79d037/0.mp4 @@ -0,0 +1 @@ +stub-clip-content-clip_55da9cbe7120 \ No newline at end of file diff --git a/tmp/in-memory-storage/clips/job_13022b79d037/1.mp4 b/tmp/in-memory-storage/clips/job_13022b79d037/1.mp4 new file mode 100644 index 0000000..4533c85 --- /dev/null +++ b/tmp/in-memory-storage/clips/job_13022b79d037/1.mp4 @@ -0,0 +1 @@ +stub-clip-content-clip_4f379a7d5941 \ No newline at end of file diff --git a/tmp/in-memory-storage/clips/job_13022b79d037/2.mp4 b/tmp/in-memory-storage/clips/job_13022b79d037/2.mp4 new file mode 100644 index 0000000..7ecd43c --- /dev/null +++ b/tmp/in-memory-storage/clips/job_13022b79d037/2.mp4 @@ -0,0 +1 @@ +stub-clip-content-clip_12f6257786bf \ No newline at end of file