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.
This commit is contained in:
JSLMPR 2026-07-09 00:28:05 +02:00
parent 3d961f2adf
commit 8cb232d70e
19 changed files with 179 additions and 9 deletions

View File

@ -75,7 +75,8 @@ public class VideoAssetService {
uploadId,
null,
now,
null
null,
now.plus(properties.getCleanup().getSourceRetentionHours(), ChronoUnit.HOURS)
);
repository.saveAsset(asset);

View File

@ -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<VideoAsset> findExpiredAssets(Instant cutoff);
List<Clip> findExpiredClips(Instant cutoff);
void saveJobEvent(JobEvent event);
List<JobEvent> findEventsByAggregateId(String aggregateId);

View File

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

View File

@ -12,6 +12,7 @@ public record Clip(
long durationSeconds,
String contentType,
long contentLengthBytes,
Instant createdAt
Instant createdAt,
Instant expiresAt
) {
}

View File

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

View File

@ -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<VideoAsset> findExpiredAssets(Instant cutoff) {
List<VideoAsset> 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<Clip> findExpiredClips(Instant cutoff) {
List<Clip> 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);

View File

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

View File

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

View File

@ -114,6 +114,20 @@ public class JpaVideoClippingRepository implements VideoClippingRepository {
return clipJpaRepository.findById(clipId).map(mapper::toDomain).orElse(null);
}
@Override
public List<VideoAsset> findExpiredAssets(java.time.Instant cutoff) {
return videoAssetJpaRepository.findByExpiresAtBeforeAndStatusNot(cutoff, "DELETED").stream()
.map(mapper::toDomain)
.toList();
}
@Override
public List<Clip> 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));

View File

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

View File

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

View File

@ -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<ClipEntity, String> {
List<ClipEntity> findByJobId(String jobId);
List<ClipEntity> findByExpiresAtBefore(Instant cutoff);
}

View File

@ -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<VideoAssetEntity, String> {
List<VideoAssetEntity> findByExpiresAtBeforeAndStatusNot(Instant cutoff, String status);
}

View File

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

View File

@ -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<GeneratedClip> 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++;

View File

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

View File

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

View File

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

View File

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