feat: JAR retention policy with nightly cleanup job
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m23s
CI / docker (push) Successful in 1m9s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 40s

Per-environment "keep last N versions" setting (default 5, null for
unlimited). Nightly scheduled job at 03:00 deletes old versions from
both database and disk, skipping any version that is currently deployed.

Full stack:
- V6 migration: adds jar_retention_count column to environments
- Environment record, repository, service, admin controller endpoint
- JarRetentionJob: @Scheduled nightly, iterates environments and apps
- UI: retention policy editor on admin Environments page with
  toggle between limited/unlimited and version count input
- AppVersionRepository.delete() for version cleanup

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-08 19:06:28 +02:00
parent 863a992cc4
commit 7e47f1628d
11 changed files with 252 additions and 3 deletions

View File

@@ -104,6 +104,24 @@ public class EnvironmentAdminController {
}
}
@PutMapping("/{id}/jar-retention")
@Operation(summary = "Update JAR retention policy for an environment")
@ApiResponse(responseCode = "200", description = "Retention policy updated")
@ApiResponse(responseCode = "404", description = "Environment not found")
public ResponseEntity<?> updateJarRetention(@PathVariable UUID id,
@RequestBody JarRetentionRequest request) {
try {
environmentService.updateJarRetentionCount(id, request.jarRetentionCount());
return ResponseEntity.ok(environmentService.getById(id));
} catch (IllegalArgumentException e) {
if (e.getMessage().contains("not found")) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
}
}
public record CreateEnvironmentRequest(String slug, String displayName, boolean production) {}
public record UpdateEnvironmentRequest(String displayName, boolean production, boolean enabled) {}
public record JarRetentionRequest(Integer jarRetentionCount) {}
}

View File

@@ -0,0 +1,112 @@
package com.cameleer3.server.app.retention;
import com.cameleer3.server.core.runtime.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Comparator;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
/**
* Nightly job that enforces JAR retention policies per environment.
* For each app, keeps the N most recent versions (configured per environment)
* and deletes older ones — unless they are currently deployed.
*/
@Component
public class JarRetentionJob {
private static final Logger log = LoggerFactory.getLogger(JarRetentionJob.class);
private final EnvironmentService environmentService;
private final AppService appService;
private final AppVersionRepository versionRepo;
private final DeploymentRepository deploymentRepo;
public JarRetentionJob(EnvironmentService environmentService,
AppService appService,
AppVersionRepository versionRepo,
DeploymentRepository deploymentRepo) {
this.environmentService = environmentService;
this.appService = appService;
this.versionRepo = versionRepo;
this.deploymentRepo = deploymentRepo;
}
@Scheduled(cron = "0 0 3 * * *") // 03:00 every day
public void cleanupOldVersions() {
log.info("JAR retention job started");
int totalDeleted = 0;
for (Environment env : environmentService.listAll()) {
Integer retentionCount = env.jarRetentionCount();
if (retentionCount == null) {
log.debug("Environment {} has unlimited retention, skipping", env.slug());
continue;
}
for (App app : appService.listByEnvironment(env.id())) {
totalDeleted += cleanupApp(app, retentionCount);
}
}
log.info("JAR retention job completed — deleted {} versions", totalDeleted);
}
private int cleanupApp(App app, int retentionCount) {
List<AppVersion> versions = versionRepo.findByAppId(app.id()); // ordered DESC by version
if (versions.size() <= retentionCount) return 0;
// Find version IDs that are currently deployed (any status)
Set<UUID> deployedVersionIds = deploymentRepo.findByAppId(app.id()).stream()
.map(Deployment::appVersionId)
.collect(Collectors.toSet());
int deleted = 0;
// versions is sorted DESC — skip the first retentionCount, delete the rest
for (int i = retentionCount; i < versions.size(); i++) {
AppVersion version = versions.get(i);
if (deployedVersionIds.contains(version.id())) {
log.debug("Skipping deployed version v{} of app {} ({})", version.version(), app.slug(), version.id());
continue;
}
// Delete JAR from disk
deleteJarFile(version);
// Delete DB record
versionRepo.delete(version.id());
deleted++;
log.info("Deleted version v{} of app {} ({}) — JAR: {}", version.version(), app.slug(), version.id(), version.jarPath());
}
return deleted;
}
private void deleteJarFile(AppVersion version) {
try {
Path jarPath = Path.of(version.jarPath());
if (Files.exists(jarPath)) {
Files.delete(jarPath);
// Try to remove the empty version directory
Path versionDir = jarPath.getParent();
if (versionDir != null && Files.isDirectory(versionDir)) {
try (var entries = Files.list(versionDir)) {
if (entries.findFirst().isEmpty()) {
Files.delete(versionDir);
}
}
}
}
} catch (IOException e) {
log.warn("Failed to delete JAR file for version {}: {}", version.id(), e.getMessage());
}
}
}

View File

@@ -49,6 +49,11 @@ public class PostgresAppVersionRepository implements AppVersionRepository {
return id;
}
@Override
public void delete(UUID id) {
jdbc.update("DELETE FROM app_versions WHERE id = ?", id);
}
private AppVersion mapRow(ResultSet rs) throws SQLException {
Long sizeBytes = rs.getLong("jar_size_bytes");
if (rs.wasNull()) sizeBytes = null;

View File

@@ -24,17 +24,19 @@ public class PostgresEnvironmentRepository implements EnvironmentRepository {
this.objectMapper = objectMapper;
}
private static final String SELECT_COLS = "id, slug, display_name, production, enabled, default_container_config, jar_retention_count, created_at";
@Override
public List<Environment> findAll() {
return jdbc.query(
"SELECT id, slug, display_name, production, enabled, default_container_config, created_at FROM environments ORDER BY created_at",
"SELECT " + SELECT_COLS + " FROM environments ORDER BY created_at",
(rs, rowNum) -> mapRow(rs));
}
@Override
public Optional<Environment> findById(UUID id) {
var results = jdbc.query(
"SELECT id, slug, display_name, production, enabled, default_container_config, created_at FROM environments WHERE id = ?",
"SELECT " + SELECT_COLS + " FROM environments WHERE id = ?",
(rs, rowNum) -> mapRow(rs), id);
return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0));
}
@@ -42,7 +44,7 @@ public class PostgresEnvironmentRepository implements EnvironmentRepository {
@Override
public Optional<Environment> findBySlug(String slug) {
var results = jdbc.query(
"SELECT id, slug, display_name, production, enabled, default_container_config, created_at FROM environments WHERE slug = ?",
"SELECT " + SELECT_COLS + " FROM environments WHERE slug = ?",
(rs, rowNum) -> mapRow(rs), slug);
return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0));
}
@@ -61,6 +63,11 @@ public class PostgresEnvironmentRepository implements EnvironmentRepository {
displayName, production, enabled, id);
}
@Override
public void updateJarRetentionCount(UUID id, Integer jarRetentionCount) {
jdbc.update("UPDATE environments SET jar_retention_count = ? WHERE id = ?", jarRetentionCount, id);
}
@Override
public void delete(UUID id) {
jdbc.update("DELETE FROM environments WHERE id = ?", id);
@@ -84,6 +91,8 @@ public class PostgresEnvironmentRepository implements EnvironmentRepository {
config = objectMapper.readValue(json, MAP_TYPE);
}
} catch (Exception e) { /* use empty default */ }
int retentionRaw = rs.getInt("jar_retention_count");
Integer jarRetentionCount = rs.wasNull() ? null : retentionRaw;
return new Environment(
UUID.fromString(rs.getString("id")),
rs.getString("slug"),
@@ -91,6 +100,7 @@ public class PostgresEnvironmentRepository implements EnvironmentRepository {
rs.getBoolean("production"),
rs.getBoolean("enabled"),
config,
jarRetentionCount,
rs.getTimestamp("created_at").toInstant()
);
}

View File

@@ -0,0 +1 @@
ALTER TABLE environments ADD COLUMN jar_retention_count INTEGER DEFAULT 5;