feat(license): LicenseRevalidationJob — daily cron + 60s post-startup

@Scheduled(cron = "0 0 3 * * *") triggers svc.revalidate() daily.
@EventListener(ApplicationReadyEvent.class) @Async fires once 60s
after boot to catch ABSENT->ACTIVE transitions if the license was
written to PG between server starts. Exceptions are logged but never
propagate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-26 15:32:33 +02:00
parent 484a55f4f4
commit 340d954fed
2 changed files with 84 additions and 0 deletions

View File

@@ -0,0 +1,58 @@
package com.cameleer.server.app.license;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.scheduling.annotation.Async;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
/**
* Daily revalidation cron + on-startup revalidation 60s after {@link ApplicationReadyEvent}.
*
* <p>The startup tick catches ABSENT-&gt;ACTIVE transitions when the license was written to
* PostgreSQL between server starts (e.g. SaaS provisioning), and gives slow downstream
* components time to come up before the first license event fires. The daily cron ensures
* expirations and clock drift are caught even in long-running deployments.</p>
*
* <p>Both invocations call {@link LicenseService#revalidate()} which is internally idempotent
* and exception-safe; this class additionally swallows any escape so a misbehaving validator
* cannot crash the scheduler thread.</p>
*/
@Component
public class LicenseRevalidationJob {
private static final Logger log = LoggerFactory.getLogger(LicenseRevalidationJob.class);
private final LicenseService svc;
public LicenseRevalidationJob(LicenseService svc) {
this.svc = svc;
}
@EventListener(ApplicationReadyEvent.class)
@Async
public void onStartup() {
try {
Thread.sleep(60_000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
revalidate();
}
@Scheduled(cron = "0 0 3 * * *")
public void daily() {
revalidate();
}
private void revalidate() {
try {
svc.revalidate();
} catch (Exception e) {
log.error("Revalidation crashed: {}", e.getMessage());
}
}
}

View File

@@ -0,0 +1,26 @@
package com.cameleer.server.app.license;
import org.junit.jupiter.api.Test;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
class LicenseRevalidationJobTest {
@Test
void daily_callsService() {
LicenseService svc = mock(LicenseService.class);
new LicenseRevalidationJob(svc).daily();
verify(svc).revalidate();
}
@Test
void daily_swallowsServiceException() {
LicenseService svc = mock(LicenseService.class);
doThrow(new RuntimeException("revalidate failed")).when(svc).revalidate();
// No exception escapes
new LicenseRevalidationJob(svc).daily();
verify(svc).revalidate();
}
}