Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 139 additions & 0 deletions src/main/java/com/clearfolio/viewer/durable/ConversionRetryRecord.java
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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.</p>
*/
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;
}
}
Original file line number Diff line number Diff line change
@@ -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));
}
}
Loading