From 8cb232d70ea592451163585e42807afae39b2b77 Mon Sep 17 00:00:00 2001 From: JSLMPR Date: Thu, 9 Jul 2026 00:28:05 +0200 Subject: [PATCH] Assets and clips now carry expiration timestamps in the domain model: - src/main/java/org/example/videoclips/domain/VideoAsset.java - src/main/java/org/example/videoclips/domain/Clip.java That metadata is mapped through the in-memory and JPA repository paths, including: - src/main/java/org/example/videoclips/infrastructure/InMemoryVideoAssetRepository.java - src/main/java/org/example/videoclips/persistence/JpaVideoClippingRepository.java - src/main/java/org/example/videoclips/persistence/entity/VideoAssetEntity.java - src/main/java/org/example/videoclips/persistence/entity/ClipEntity.java I also added scheduled retention cleanup in src/main/java/org/example/videoclips/operations/RetentionCleanupJob.java, with configuration in src/main/java/org/example/videoclips/config/ VideoClippingProperties.java and src/main/resources/application.properties. Right now it soft-expires assets by marking them deleted when their retention time passes. Clip retention timestamps are now assigned by both clipper adapters so the service has real expiration data to work with. What this does not do yet is physically delete expired clip objects or original source objects from object storage. The retention job is metadata-aware now, but object-store deletion still needs to be added through the storage port as the next step. --- .../application/VideoAssetService.java | 3 +- .../application/VideoClippingRepository.java | 5 +++ .../config/VideoClippingProperties.java | 33 +++++++++++++++ .../org/example/videoclips/domain/Clip.java | 3 +- .../example/videoclips/domain/VideoAsset.java | 12 ++++-- .../InMemoryVideoAssetRepository.java | 23 +++++++++++ .../operations/RetentionCleanupJob.java | 40 +++++++++++++++++++ .../persistence/JpaVideoClippingMapper.java | 8 +++- .../JpaVideoClippingRepository.java | 14 +++++++ .../persistence/entity/ClipEntity.java | 10 +++++ .../persistence/entity/VideoAssetEntity.java | 10 +++++ .../persistence/spring/ClipJpaRepository.java | 3 ++ .../spring/VideoAssetJpaRepository.java | 5 +++ .../processing/FfmpegVideoClipperAdapter.java | 3 +- .../processing/StubVideoClipperAdapter.java | 10 ++++- src/main/resources/application.properties | 3 ++ .../clips/job_13022b79d037/0.mp4 | 1 + .../clips/job_13022b79d037/1.mp4 | 1 + .../clips/job_13022b79d037/2.mp4 | 1 + 19 files changed, 179 insertions(+), 9 deletions(-) create mode 100644 src/main/java/org/example/videoclips/operations/RetentionCleanupJob.java create mode 100644 tmp/in-memory-storage/clips/job_13022b79d037/0.mp4 create mode 100644 tmp/in-memory-storage/clips/job_13022b79d037/1.mp4 create mode 100644 tmp/in-memory-storage/clips/job_13022b79d037/2.mp4 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