diff --git a/src/main/java/com/clearfolio/viewer/durable/ConversionRetryRecord.java b/src/main/java/com/clearfolio/viewer/durable/ConversionRetryRecord.java new file mode 100644 index 00000000..91172528 --- /dev/null +++ b/src/main/java/com/clearfolio/viewer/durable/ConversionRetryRecord.java @@ -0,0 +1,139 @@ +package com.clearfolio.viewer.durable; + +import java.time.Instant; +import java.util.Objects; +import java.util.UUID; + +/** + * Immutable durable schedule record for one conversion retry attempt. + * + *
The record binds retry authority to an exact permanently identified job + * generation and attempt number. Persisting this state allows an execution + * adapter to recover retry timing after process restart instead of treating an + * in-memory delayed task as the source of truth.
+ */ +public final class ConversionRetryRecord { + + private final UUID retryId; + private final UUID jobId; + private final long generation; + private final int attempt; + private final Instant dueAt; + + private ConversionRetryRecord( + UUID retryId, + UUID jobId, + long generation, + int attempt, + Instant dueAt) { + this.retryId = retryId; + this.jobId = jobId; + this.generation = generation; + this.attempt = attempt; + this.dueAt = dueAt; + } + + /** + * Creates one durable retry schedule entry. + * + * @param retryId unique identifier for this scheduled retry record + * @param jobId permanently reserved conversion-job identifier + * @param generation positive lifecycle generation authorized for the retry + * @param attempt positive attempt number represented by this schedule entry + * @param dueAt persisted instant when the retry becomes eligible + * @return immutable retry schedule record + * @throws NullPointerException when an identifier or due timestamp is null + * @throws IllegalArgumentException when generation or attempt is not positive + */ + public static ConversionRetryRecord schedule( + UUID retryId, + UUID jobId, + long generation, + int attempt, + Instant dueAt) { + UUID requiredRetryId = Objects.requireNonNull(retryId, "retryId"); + UUID requiredJobId = Objects.requireNonNull(jobId, "jobId"); + Instant requiredDueAt = Objects.requireNonNull(dueAt, "dueAt"); + if (generation <= 0L) { + throw new IllegalArgumentException("generation must be positive"); + } + if (attempt <= 0) { + throw new IllegalArgumentException("attempt must be positive"); + } + return new ConversionRetryRecord( + requiredRetryId, + requiredJobId, + generation, + attempt, + requiredDueAt + ); + } + + /** + * Returns whether this retry is eligible at the supplied instant. + * + * @param now evaluation timestamp + * @return true at or after the persisted due timestamp + * @throws NullPointerException when {@code now} is null + */ + public boolean isDue(Instant now) { + Instant requiredNow = Objects.requireNonNull(now, "now"); + return !requiredNow.isBefore(dueAt); + } + + /** + * Checks whether candidate job authority matches this scheduled generation. + * + * @param candidateJobId candidate conversion-job identifier + * @param candidateGeneration candidate lifecycle generation + * @return true only when both authority components exactly match + */ + public boolean authorizes(UUID candidateJobId, long candidateGeneration) { + return jobId.equals(candidateJobId) && generation == candidateGeneration; + } + + /** + * Returns the unique retry schedule identifier. + * + * @return retry record identifier + */ + public UUID retryId() { + return retryId; + } + + /** + * Returns the conversion job whose retry is scheduled. + * + * @return conversion-job identifier + */ + public UUID jobId() { + return jobId; + } + + /** + * Returns the lifecycle generation fenced by this retry record. + * + * @return positive lifecycle generation + */ + public long generation() { + return generation; + } + + /** + * Returns the positive attempt number represented by this record. + * + * @return retry attempt number + */ + public int attempt() { + return attempt; + } + + /** + * Returns the persisted instant when the retry becomes eligible. + * + * @return due timestamp + */ + public Instant dueAt() { + return dueAt; + } +} diff --git a/src/test/java/com/clearfolio/viewer/durable/ConversionRetryRecordTest.java b/src/test/java/com/clearfolio/viewer/durable/ConversionRetryRecordTest.java new file mode 100644 index 00000000..75687d4c --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/durable/ConversionRetryRecordTest.java @@ -0,0 +1,91 @@ +package com.clearfolio.viewer.durable; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.Instant; +import java.util.UUID; + +import org.junit.jupiter.api.Test; + +class ConversionRetryRecordTest { + + @Test + void retryRecordBindsExactJobGenerationAttemptAndDueTime() { + UUID retryId = UUID.randomUUID(); + UUID jobId = UUID.randomUUID(); + Instant dueAt = Instant.parse("2026-08-11T01:00:00Z"); + + ConversionRetryRecord record = ConversionRetryRecord.schedule( + retryId, + jobId, + 5L, + 2, + dueAt + ); + + assertEquals(retryId, record.retryId()); + assertEquals(jobId, record.jobId()); + assertEquals(5L, record.generation()); + assertEquals(2, record.attempt()); + assertEquals(dueAt, record.dueAt()); + } + + @Test + void dueEvaluationUsesPersistedDueTimeInclusively() { + Instant dueAt = Instant.parse("2026-08-11T01:00:00Z"); + ConversionRetryRecord record = ConversionRetryRecord.schedule( + UUID.randomUUID(), + UUID.randomUUID(), + 1L, + 1, + dueAt + ); + + assertFalse(record.isDue(dueAt.minusNanos(1))); + assertTrue(record.isDue(dueAt)); + assertTrue(record.isDue(dueAt.plusNanos(1))); + assertThrows(NullPointerException.class, () -> record.isDue(null)); + } + + @Test + void generationFenceRejectsAnotherJobOrGeneration() { + UUID jobId = UUID.randomUUID(); + ConversionRetryRecord record = ConversionRetryRecord.schedule( + UUID.randomUUID(), + jobId, + 3L, + 1, + Instant.parse("2026-08-11T01:00:00Z") + ); + + assertTrue(record.authorizes(jobId, 3L)); + assertFalse(record.authorizes(UUID.randomUUID(), 3L)); + assertFalse(record.authorizes(jobId, 4L)); + assertFalse(record.authorizes(null, 3L)); + } + + @Test + void constructionFailsClosedForMissingOrNonPositiveAuthority() { + UUID retryId = UUID.randomUUID(); + UUID jobId = UUID.randomUUID(); + Instant dueAt = Instant.parse("2026-08-11T01:00:00Z"); + + assertThrows(NullPointerException.class, + () -> ConversionRetryRecord.schedule(null, jobId, 1L, 1, dueAt)); + assertThrows(NullPointerException.class, + () -> ConversionRetryRecord.schedule(retryId, null, 1L, 1, dueAt)); + assertThrows(NullPointerException.class, + () -> ConversionRetryRecord.schedule(retryId, jobId, 1L, 1, null)); + assertThrows(IllegalArgumentException.class, + () -> ConversionRetryRecord.schedule(retryId, jobId, 0L, 1, dueAt)); + assertThrows(IllegalArgumentException.class, + () -> ConversionRetryRecord.schedule(retryId, jobId, -1L, 1, dueAt)); + assertThrows(IllegalArgumentException.class, + () -> ConversionRetryRecord.schedule(retryId, jobId, 1L, 0, dueAt)); + assertThrows(IllegalArgumentException.class, + () -> ConversionRetryRecord.schedule(retryId, jobId, 1L, -1, dueAt)); + } +}