Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
package com.clearfolio.viewer.durable;

import java.time.Instant;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;

/**
* Immutable durable-dispatch identity for one accepted conversion generation.
*
* <p>A pending record exists only after acceptance authority has been
* established for the bound job generation. Dispatch acknowledgement creates a
* new terminal snapshot while preserving that acceptance identity. Repeated
* acknowledgement is idempotent so broker redelivery cannot move the recorded
* dispatch boundary after it has been established.</p>
*/
public final class ConversionOutboxRecord {

private final UUID outboxId;
private final UUID jobId;
private final long generation;
private final Instant acceptedAt;
private final Instant dispatchedAt;

private ConversionOutboxRecord(
UUID outboxId,
UUID jobId,
long generation,
Instant acceptedAt,
Instant dispatchedAt) {
this.outboxId = outboxId;
this.jobId = jobId;
this.generation = generation;
this.acceptedAt = acceptedAt;
this.dispatchedAt = dispatchedAt;
}

/**
* Creates a pending outbox record for one durably accepted job generation.
*
* @param outboxId unique durable dispatch identifier
* @param jobId canonical accepted conversion-job identifier
* @param generation positive lifecycle generation being dispatched
* @param acceptedAt durable acceptance timestamp
* @return pending outbox record
* @throws NullPointerException when an identifier or timestamp is null
* @throws IllegalArgumentException when {@code generation} is not positive
*/
public static ConversionOutboxRecord pending(
UUID outboxId,
UUID jobId,
long generation,
Instant acceptedAt) {
UUID requiredOutboxId = Objects.requireNonNull(outboxId, "outboxId");
UUID requiredJobId = Objects.requireNonNull(jobId, "jobId");
Instant requiredAcceptedAt = Objects.requireNonNull(acceptedAt, "acceptedAt");
if (generation <= 0L) {
throw new IllegalArgumentException("generation must be positive");
}
return new ConversionOutboxRecord(
requiredOutboxId,
requiredJobId,
generation,
requiredAcceptedAt,
null
);
}

/**
* Returns a terminal dispatch snapshot for this exact acceptance identity.
*
* <p>If this record was already dispatched, the current snapshot is
* returned unchanged. This keeps repeated broker acknowledgements
* idempotent.</p>
*
* @param dispatchedAt timestamp when dispatch authority was established
* @return terminal dispatch snapshot, or this object when already dispatched
* @throws NullPointerException when {@code dispatchedAt} is null
* @throws IllegalArgumentException when dispatch predates durable acceptance
*/
public ConversionOutboxRecord markDispatched(Instant dispatchedAt) {
Instant requiredDispatchedAt = Objects.requireNonNull(dispatchedAt, "dispatchedAt");
if (this.dispatchedAt != null) {
return this;
}
if (requiredDispatchedAt.isBefore(acceptedAt)) {
throw new IllegalArgumentException("dispatchedAt cannot predate acceptedAt");
}
return new ConversionOutboxRecord(
outboxId,
jobId,
generation,
acceptedAt,
requiredDispatchedAt
);
}

/**
* Returns the unique durable dispatch identifier.
*
* @return outbox identifier
*/
public UUID outboxId() {
return outboxId;
}

/**
* Returns the canonical conversion-job identifier.
*
* @return conversion-job identifier
*/
public UUID jobId() {
return jobId;
}

/**
* Returns the lifecycle generation authorized by this dispatch record.
*
* @return positive lifecycle generation
*/
public long generation() {
return generation;
}

/**
* Returns when durable acceptance authority was established.
*
* @return durable acceptance timestamp
*/
public Instant acceptedAt() {
return acceptedAt;
}

/**
* Returns whether broker dispatch is still pending.
*
* @return true until the first dispatch acknowledgement is recorded
*/
public boolean isPending() {
return dispatchedAt == null;
}

/**
* Returns the first broker dispatch acknowledgement when present.
*
* @return dispatch timestamp, or empty while pending
*/
public Optional<Instant> dispatchedAt() {
return Optional.ofNullable(dispatchedAt);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
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.assertSame;
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 ConversionOutboxRecordTest {

@Test
void pendingRecordBindsExactAcceptedJobGeneration() {
UUID outboxId = UUID.randomUUID();
UUID jobId = UUID.randomUUID();
Instant acceptedAt = Instant.parse("2026-08-11T00:00:00Z");

ConversionOutboxRecord record = ConversionOutboxRecord.pending(
outboxId,
jobId,
4L,
acceptedAt
);

assertEquals(outboxId, record.outboxId());
assertEquals(jobId, record.jobId());
assertEquals(4L, record.generation());
assertEquals(acceptedAt, record.acceptedAt());
assertTrue(record.isPending());
assertFalse(record.dispatchedAt().isPresent());
}

@Test
void dispatchCreatesTerminalReceiptWithoutChangingAcceptanceIdentity() {
UUID outboxId = UUID.randomUUID();
UUID jobId = UUID.randomUUID();
Instant acceptedAt = Instant.parse("2026-08-11T00:00:00Z");
Instant dispatchedAt = acceptedAt.plusSeconds(2);
ConversionOutboxRecord pending = ConversionOutboxRecord.pending(
outboxId,
jobId,
9L,
acceptedAt
);

ConversionOutboxRecord dispatched = pending.markDispatched(dispatchedAt);

assertEquals(outboxId, dispatched.outboxId());
assertEquals(jobId, dispatched.jobId());
assertEquals(9L, dispatched.generation());
assertEquals(acceptedAt, dispatched.acceptedAt());
assertFalse(dispatched.isPending());
assertEquals(dispatchedAt, dispatched.dispatchedAt().orElseThrow());
}

@Test
void duplicateDispatchAcknowledgementIsIdempotent() {
Instant acceptedAt = Instant.parse("2026-08-11T00:00:00Z");
Instant firstDispatchedAt = acceptedAt.plusSeconds(1);
ConversionOutboxRecord dispatched = ConversionOutboxRecord.pending(
UUID.randomUUID(),
UUID.randomUUID(),
1L,
acceptedAt
).markDispatched(firstDispatchedAt);

assertSame(dispatched, dispatched.markDispatched(acceptedAt.plusSeconds(2)));
assertEquals(firstDispatchedAt, dispatched.dispatchedAt().orElseThrow());
}

@Test
void dispatchAtDurableAcceptanceBoundaryIsAccepted() {
Instant acceptedAt = Instant.parse("2026-08-11T00:00:00Z");
ConversionOutboxRecord dispatched = ConversionOutboxRecord.pending(
UUID.randomUUID(),
UUID.randomUUID(),
1L,
acceptedAt
).markDispatched(acceptedAt);

assertFalse(dispatched.isPending());
assertEquals(acceptedAt, dispatched.dispatchedAt().orElseThrow());
}

@Test
void dispatchCannotPredateDurableAcceptance() {
Instant acceptedAt = Instant.parse("2026-08-11T00:00:00Z");
ConversionOutboxRecord pending = ConversionOutboxRecord.pending(
UUID.randomUUID(),
UUID.randomUUID(),
1L,
acceptedAt
);

assertThrows(IllegalArgumentException.class,
() -> pending.markDispatched(acceptedAt.minusNanos(1)));
assertThrows(NullPointerException.class, () -> pending.markDispatched(null));
}

@Test
void constructionFailsClosedForMissingOrInvalidAuthority() {
UUID outboxId = UUID.randomUUID();
UUID jobId = UUID.randomUUID();
Instant acceptedAt = Instant.parse("2026-08-11T00:00:00Z");

assertThrows(NullPointerException.class,
() -> ConversionOutboxRecord.pending(null, jobId, 1L, acceptedAt));
assertThrows(NullPointerException.class,
() -> ConversionOutboxRecord.pending(outboxId, null, 1L, acceptedAt));
assertThrows(NullPointerException.class,
() -> ConversionOutboxRecord.pending(outboxId, jobId, 1L, null));
assertThrows(IllegalArgumentException.class,
() -> ConversionOutboxRecord.pending(outboxId, jobId, 0L, acceptedAt));
assertThrows(IllegalArgumentException.class,
() -> ConversionOutboxRecord.pending(outboxId, jobId, -1L, acceptedAt));
}
}
Loading