feat(dict): 7-day retention auto-purge + inline restore UI
This commit is contained in:
+102
@@ -0,0 +1,102 @@
|
||||
package cloud.nstart.terravault.ordinis.restapi.service;
|
||||
|
||||
import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinition;
|
||||
import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinitionRepository;
|
||||
import io.micrometer.core.instrument.Counter;
|
||||
import io.micrometer.core.instrument.MeterRegistry;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Permanent purge для soft-deleted справочников.
|
||||
*
|
||||
* <p>Retention 7 дней (NSI governance policy — юзер: «договаривались же
|
||||
* 7 дней хранится и только потом удаляется и в течение 7 дней можно
|
||||
* вернуть словарь»). После {@code deleted_at + 7d} dict physically
|
||||
* removed через FK CASCADE (migration 0030): definition row + cascade на
|
||||
* dictionary_records + record_drafts + dictionary_schema_drafts. Audit_log
|
||||
* остаётся (нет FK) как permanent trail.
|
||||
*
|
||||
* <p>Cron каждый час. Если purge fails — log + counter, не блокируем
|
||||
* остальные jobs. Idempotent: следующий run попробует снова.
|
||||
*
|
||||
* <p>Recovery window: 0–7 дней после soft-delete admin может вызвать
|
||||
* {@code POST /api/v1/dictionaries/{name}/restore}. После purge — только
|
||||
* через DB backup.
|
||||
*/
|
||||
@Component
|
||||
public class DictionaryPurgeJob {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(DictionaryPurgeJob.class);
|
||||
|
||||
/** 7 дней — NSI retention policy. Override через property для testing. */
|
||||
private static final Duration RETENTION = Duration.ofDays(7);
|
||||
|
||||
private final DictionaryDefinitionRepository repository;
|
||||
private final Counter purgedCounter;
|
||||
private final Counter errorCounter;
|
||||
|
||||
public DictionaryPurgeJob(
|
||||
DictionaryDefinitionRepository repository, MeterRegistry meterRegistry) {
|
||||
this.repository = repository;
|
||||
this.purgedCounter = Counter.builder("ordinis_dictionary_purged_total")
|
||||
.description("Permanently purged soft-deleted dictionaries (после 7d retention)")
|
||||
.register(meterRegistry);
|
||||
this.errorCounter = Counter.builder("ordinis_dictionary_purge_errors_total")
|
||||
.description("Purge attempts что failed (rollback safe — retry on next run)")
|
||||
.register(meterRegistry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cron каждый час в :23 (random minute — не competing с idempotency
|
||||
* cleanup at :17, projection refresh, и др.).
|
||||
*
|
||||
* <p>Override через property {@code ordinis.dictionary.purge-cron} для
|
||||
* test/staging если нужно faster purge cycle.
|
||||
*/
|
||||
@Scheduled(cron = "${ordinis.dictionary.purge-cron:0 23 * * * *}")
|
||||
@Transactional
|
||||
public void purge() {
|
||||
OffsetDateTime cutoff = OffsetDateTime.now().minus(RETENTION);
|
||||
List<DictionaryDefinition> candidates = repository.findAllDeleted().stream()
|
||||
.filter(d -> d.getDeletedAt() != null && d.getDeletedAt().isBefore(cutoff))
|
||||
.toList();
|
||||
|
||||
if (candidates.isEmpty()) {
|
||||
log.debug("Dictionary purge: 0 candidates (retention {} days, cutoff {})",
|
||||
RETENTION.toDays(), cutoff);
|
||||
return;
|
||||
}
|
||||
|
||||
log.info("Dictionary purge: найдено {} кандидатов на physical delete "
|
||||
+ "(deleted_at < {})", candidates.size(), cutoff);
|
||||
|
||||
int purged = 0;
|
||||
for (DictionaryDefinition d : candidates) {
|
||||
try {
|
||||
// FK ON DELETE CASCADE (migration 0030) cleanup'ит:
|
||||
// dictionary_records + record_drafts + dictionary_schema_drafts.
|
||||
// audit_log + outbox_events не имеют FK — остаются permanent trail.
|
||||
repository.deleteById(d.getId());
|
||||
log.info("Dictionary purged: name={} id={} deleted_at={} (>{} days ago)",
|
||||
d.getName(), d.getId(), d.getDeletedAt(), RETENTION.toDays());
|
||||
purged++;
|
||||
} catch (Exception e) {
|
||||
// Не throw — продолжаем остальные. Retry на следующем cron run.
|
||||
log.error("Dictionary purge failed для name={}: {}", d.getName(), e.getMessage());
|
||||
errorCounter.increment();
|
||||
}
|
||||
}
|
||||
|
||||
if (purged > 0) {
|
||||
purgedCounter.increment(purged);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user