diff --git a/src/main/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepository.java b/src/main/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepository.java
index 03fd355bc..4d50673ea 100644
--- a/src/main/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepository.java
+++ b/src/main/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepository.java
@@ -2,12 +2,12 @@
import java.time.Instant;
import java.util.List;
+import java.util.Objects;
import java.util.Optional;
+import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentLinkedQueue;
-import java.util.concurrent.atomic.AtomicBoolean;
-import java.util.concurrent.atomic.AtomicReference;
import org.springframework.stereotype.Repository;
@@ -16,6 +16,11 @@
/**
* In-memory repository implementation for conversion job persistence.
+ *
+ *
Once stored, a job identifier remains reserved for the lifetime of this
+ * repository instance. Deletion removes the live job and secondary index but
+ * does not make the identifier reusable. That prevents delayed asynchronous
+ * work from resolving an old identifier to a different job or tenant.
*/
@Repository
public class InMemoryConversionJobRepository implements ConversionJobRepository, ConversionJobStateStore {
@@ -27,10 +32,13 @@ public class InMemoryConversionJobRepository implements ConversionJobRepository,
private static final String EVENT_JOB_SUCCEEDED = "conversion.job.succeeded";
private static final String EVENT_JOB_FAILED = "conversion.job.failed";
private static final String EVENT_RETRY_ACCEPTED = "conversion.retry.accepted";
+ private static final String IDENTIFIER_COLLISION_MESSAGE = "Conversion job identifier collision.";
private final ConcurrentHashMap jobs = new ConcurrentHashMap<>();
private final ConcurrentHashMap jobsByTenantAndContentHash = new ConcurrentHashMap<>();
+ private final Set reservedJobIdentifiers = ConcurrentHashMap.newKeySet();
private final ConcurrentLinkedQueue lifecycleEvents = new ConcurrentLinkedQueue<>();
+ private final Object jobIndexLock = new Object();
/**
* Creates an empty thread-safe process-local job repository and lifecycle ledger.
@@ -44,11 +52,26 @@ public InMemoryConversionJobRepository() {
*/
@Override
public ConversionJob save(ConversionJob job) {
- jobs.put(job.getJobId(), job);
- if (job.getContentHash() != null && !job.getContentHash().isBlank()) {
- jobsByTenantAndContentHash.putIfAbsent(contentKey(job.getTenantId(), job.getContentHash()), job.getJobId());
+ Objects.requireNonNull(job, "job");
+ synchronized (jobIndexLock) {
+ UUID jobId = Objects.requireNonNull(job.getJobId(), "job.jobId");
+ ConversionJob existing = jobs.get(jobId);
+ if (existing == job) {
+ return existing;
+ }
+ rejectReservedIdentifier(jobId, existing);
+
+ String contentHash = job.getContentHash();
+ reservedJobIdentifiers.add(jobId);
+ jobs.put(jobId, job);
+ if (contentHash != null && !contentHash.isBlank()) {
+ jobsByTenantAndContentHash.putIfAbsent(
+ contentKey(job.getTenantId(), contentHash),
+ jobId
+ );
+ }
+ return job;
}
- return job;
}
/**
@@ -56,37 +79,36 @@ public ConversionJob save(ConversionJob job) {
*/
@Override
public ConversionJobRepository.FindOrStoreResult findOrStoreByContentHash(ConversionJob candidate) {
- String contentHash = candidate.getContentHash();
- if (contentHash == null || contentHash.isBlank()) {
- save(candidate);
+ Objects.requireNonNull(candidate, "candidate");
+ synchronized (jobIndexLock) {
+ UUID candidateJobId = Objects.requireNonNull(candidate.getJobId(), "candidate.jobId");
+ ConversionJob existingById = jobs.get(candidateJobId);
+ if (existingById == candidate) {
+ appendLifecycleEvent(existingById, EVENT_DEDUPE_HIT, null);
+ return new ConversionJobRepository.FindOrStoreResult(existingById, false);
+ }
+ rejectReservedIdentifier(candidateJobId, existingById);
+
+ String contentHash = candidate.getContentHash();
+ if (contentHash == null || contentHash.isBlank()) {
+ storeNewCandidate(candidate);
+ appendLifecycleEvent(candidate, EVENT_SUBMITTED, null);
+ return new ConversionJobRepository.FindOrStoreResult(candidate, true);
+ }
+
+ String contentIndexKey = contentKey(candidate.getTenantId(), contentHash);
+ UUID existingJobId = jobsByTenantAndContentHash.get(contentIndexKey);
+ ConversionJob existingByContent = existingJobId == null ? null : jobs.get(existingJobId);
+ if (existingByContent != null) {
+ appendLifecycleEvent(existingByContent, EVENT_DEDUPE_HIT, null);
+ return new ConversionJobRepository.FindOrStoreResult(existingByContent, false);
+ }
+
+ storeNewCandidate(candidate);
+ jobsByTenantAndContentHash.put(contentIndexKey, candidateJobId);
appendLifecycleEvent(candidate, EVENT_SUBMITTED, null);
return new ConversionJobRepository.FindOrStoreResult(candidate, true);
}
-
- String contentKey = contentKey(candidate.getTenantId(), contentHash);
- AtomicBoolean created = new AtomicBoolean(false);
- AtomicReference canonical = new AtomicReference<>();
- jobsByTenantAndContentHash.compute(
- contentKey,
- (key, existingJobId) -> {
- if (existingJobId != null) {
- ConversionJob existing = jobs.get(existingJobId);
- if (existing != null) {
- canonical.set(existing);
- return existingJobId;
- }
- }
-
- jobs.put(candidate.getJobId(), candidate);
- created.set(true);
- canonical.set(candidate);
- return candidate.getJobId();
- }
- );
-
- ConversionJob storedJob = canonical.get();
- appendLifecycleEvent(storedJob, created.get() ? EVENT_SUBMITTED : EVENT_DEDUPE_HIT, null);
- return new ConversionJobRepository.FindOrStoreResult(canonical.get(), created.get());
}
/**
@@ -149,9 +171,14 @@ public List findAllByTenantId(String tenantId) {
*/
@Override
public void deleteById(UUID jobId) {
- ConversionJob removed = jobs.remove(jobId);
- if (removed != null && removed.getContentHash() != null && !removed.getContentHash().isBlank()) {
- jobsByTenantAndContentHash.remove(contentKey(removed.getTenantId(), removed.getContentHash()), jobId);
+ synchronized (jobIndexLock) {
+ ConversionJob removed = jobs.remove(jobId);
+ if (removed != null && removed.getContentHash() != null && !removed.getContentHash().isBlank()) {
+ jobsByTenantAndContentHash.remove(
+ contentKey(removed.getTenantId(), removed.getContentHash()),
+ jobId
+ );
+ }
}
}
@@ -257,6 +284,19 @@ public boolean retryDeadLettered(UUID jobId, String operatorId) {
return true;
}
+ private void storeNewCandidate(ConversionJob candidate) {
+ UUID jobId = Objects.requireNonNull(candidate.getJobId(), "candidate.jobId");
+ rejectReservedIdentifier(jobId, jobs.get(jobId));
+ reservedJobIdentifiers.add(jobId);
+ jobs.put(jobId, candidate);
+ }
+
+ private void rejectReservedIdentifier(UUID jobId, ConversionJob existing) {
+ if (existing != null || reservedJobIdentifiers.contains(jobId)) {
+ throw new IllegalStateException(IDENTIFIER_COLLISION_MESSAGE);
+ }
+ }
+
private String contentKey(String tenantId, String contentHash) {
return normalizeTenantId(tenantId) + "\u001f" + contentHash;
}
diff --git a/src/test/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepositoryIdentifierReservationTest.java b/src/test/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepositoryIdentifierReservationTest.java
new file mode 100644
index 000000000..4f0088d98
--- /dev/null
+++ b/src/test/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepositoryIdentifierReservationTest.java
@@ -0,0 +1,128 @@
+package com.clearfolio.viewer.repository;
+
+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.util.UUID;
+
+import org.junit.jupiter.api.Test;
+
+import com.clearfolio.viewer.model.ConversionJob;
+
+/**
+ * Proves that process-local job identifiers have immutable ownership and cannot
+ * be rebound to a different job object, including after deletion.
+ */
+class InMemoryConversionJobRepositoryIdentifierReservationTest {
+
+ private static final String COLLISION_MESSAGE = "Conversion job identifier collision.";
+
+ @Test
+ void exactLiveObjectIsAnIdempotentFindOrStoreHit() {
+ InMemoryConversionJobRepository repository = new InMemoryConversionJobRepository();
+ ConversionJob job = job(UUID.randomUUID(), "tenant-north", "shared-content-hash");
+ repository.save(job);
+
+ ConversionJobRepository.FindOrStoreResult result = repository.findOrStoreByContentHash(job);
+
+ assertFalse(result.created());
+ assertSame(job, result.canonicalJob());
+ }
+
+ @Test
+ void distinctObjectCannotReplaceLiveIdentifier() {
+ InMemoryConversionJobRepository repository = new InMemoryConversionJobRepository();
+ UUID sharedJobId = UUID.randomUUID();
+ ConversionJob existing = job(sharedJobId, "tenant-north", "existing-content-hash");
+ ConversionJob collision = job(sharedJobId, "tenant-south", "replacement-content-hash");
+ repository.save(existing);
+
+ IllegalStateException exception = assertThrows(
+ IllegalStateException.class,
+ () -> repository.save(collision)
+ );
+
+ assertEquals(COLLISION_MESSAGE, exception.getMessage());
+ assertSame(existing, repository.findById(sharedJobId).orElseThrow());
+ assertTrue(repository.findByTenantAndContentHash(
+ "tenant-south",
+ "replacement-content-hash"
+ ).isEmpty());
+ }
+
+ @Test
+ void collisionIsRejectedBeforeCandidateContentHashAccess() {
+ InMemoryConversionJobRepository repository = new InMemoryConversionJobRepository();
+ UUID sharedJobId = UUID.randomUUID();
+ ConversionJob existing = job(sharedJobId, "tenant-north", "existing-content-hash");
+ repository.save(existing);
+
+ IllegalStateException exception = assertThrows(
+ IllegalStateException.class,
+ () -> repository.findOrStoreByContentHash(new HashAccessFailureJob(sharedJobId))
+ );
+
+ assertEquals(COLLISION_MESSAGE, exception.getMessage());
+ assertSame(existing, repository.findById(sharedJobId).orElseThrow());
+ }
+
+ @Test
+ void deletedIdentifierRemainsReservedAgainstLaterReplacement() {
+ InMemoryConversionJobRepository repository = new InMemoryConversionJobRepository();
+ UUID sharedJobId = UUID.randomUUID();
+ ConversionJob original = job(sharedJobId, "tenant-north", "original-content-hash");
+ repository.save(original);
+ repository.deleteById(sharedJobId);
+
+ ConversionJob replacement = job(sharedJobId, "tenant-south", "replacement-content-hash");
+ IllegalStateException exception = assertThrows(
+ IllegalStateException.class,
+ () -> repository.findOrStoreByContentHash(replacement)
+ );
+
+ assertEquals(COLLISION_MESSAGE, exception.getMessage());
+ assertTrue(repository.findById(sharedJobId).isEmpty());
+ assertTrue(repository.findByTenantAndContentHash(
+ "tenant-south",
+ "replacement-content-hash"
+ ).isEmpty());
+ }
+
+ private static ConversionJob job(UUID jobId, String tenantId, String contentHash) {
+ return new ConversionJob(
+ jobId,
+ tenantId,
+ "owner",
+ "document.pdf",
+ "application/pdf",
+ contentHash,
+ 100L,
+ 3
+ );
+ }
+
+ /** Candidate whose content hash must not be read after a UUID collision. */
+ private static final class HashAccessFailureJob extends ConversionJob {
+
+ private HashAccessFailureJob(UUID jobId) {
+ super(
+ jobId,
+ "tenant-south",
+ "owner",
+ "collision.pdf",
+ "application/pdf",
+ "constructor-content-hash",
+ 100L,
+ 3
+ );
+ }
+
+ @Override
+ public String getContentHash() {
+ throw new IllegalStateException("candidate content hash was accessed first");
+ }
+ }
+}