diff --git a/src/main/java/com/clearfolio/viewer/lifecycle/ArtifactLifecycleLockRegistry.java b/src/main/java/com/clearfolio/viewer/lifecycle/ArtifactLifecycleLockRegistry.java new file mode 100644 index 000000000..8bb61c263 --- /dev/null +++ b/src/main/java/com/clearfolio/viewer/lifecycle/ArtifactLifecycleLockRegistry.java @@ -0,0 +1,81 @@ +package com.clearfolio.viewer.lifecycle; + +import java.util.Objects; +import java.util.UUID; +import java.util.concurrent.locks.ReentrantLock; +import java.util.function.Supplier; + +import org.springframework.stereotype.Component; + +/** + * Provides fixed-memory, process-local serialization for one document lifecycle. + * + *

Conversion and deletion must not write or remove bytes for the same + * permanently reserved job identifier at the same time. The registry hashes + * identifiers onto a fixed number of lock stripes, so memory use remains + * bounded while unrelated jobs may proceed concurrently unless they share a + * stripe.

+ * + *

This reference registry protects one JVM. Multi-instance deployments must + * provide an equivalent distributed generation fence, object-version + * precondition, or transactional outbox consumer guarantee.

+ */ +@Component +public final class ArtifactLifecycleLockRegistry { + + private static final int DEFAULT_STRIPE_COUNT = 256; + private static final ArtifactLifecycleLockRegistry SHARED = + new ArtifactLifecycleLockRegistry(DEFAULT_STRIPE_COUNT); + + private final ReentrantLock[] locks; + + /** + * Creates a registry with the default fixed number of lock stripes. + */ + public ArtifactLifecycleLockRegistry() { + this(DEFAULT_STRIPE_COUNT); + } + + ArtifactLifecycleLockRegistry(int stripeCount) { + if (stripeCount <= 0) { + throw new IllegalArgumentException("stripeCount must be positive"); + } + this.locks = new ReentrantLock[stripeCount]; + for (int index = 0; index < stripeCount; index++) { + locks[index] = new ReentrantLock(); + } + } + + /** + * Returns the process-wide registry used by standalone constructors. + * + * @return shared fixed-memory lifecycle lock registry + */ + public static ArtifactLifecycleLockRegistry shared() { + return SHARED; + } + + /** + * Executes one action while holding the stripe for a conversion job. + * + *

The lock is always released, including when the supplied action throws. + * The action's return value or runtime exception is passed through unchanged.

+ * + * @param jobId permanently reserved conversion-job identifier + * @param action work that reads or mutates metadata and artifact bytes + * @param action return type + * @return value returned by the action + * @throws NullPointerException when the identifier or action is absent + */ + public T withJobLock(UUID jobId, Supplier action) { + UUID requiredJobId = Objects.requireNonNull(jobId, "jobId"); + Supplier requiredAction = Objects.requireNonNull(action, "action"); + ReentrantLock lock = locks[Math.floorMod(requiredJobId.hashCode(), locks.length)]; + lock.lock(); + try { + return requiredAction.get(); + } finally { + lock.unlock(); + } + } +} diff --git a/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactLifecycleLockRegistryTest.java b/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactLifecycleLockRegistryTest.java new file mode 100644 index 000000000..c09be637a --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactLifecycleLockRegistryTest.java @@ -0,0 +1,101 @@ +package com.clearfolio.viewer.lifecycle; + +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 java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.Test; + +/** + * Verifies fixed-memory process-local serialization for artifact lifecycles. + */ +class ArtifactLifecycleLockRegistryTest { + + @Test + void sameJobCannotEnterTwoArtifactLifecyclesConcurrently() throws Exception { + ArtifactLifecycleLockRegistry registry = new ArtifactLifecycleLockRegistry(8); + UUID jobId = UUID.fromString("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"); + CountDownLatch firstEntered = new CountDownLatch(1); + CountDownLatch releaseFirst = new CountDownLatch(1); + CountDownLatch secondStarted = new CountDownLatch(1); + CountDownLatch secondEntered = new CountDownLatch(1); + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + Future first = executor.submit(() -> registry.withJobLock(jobId, () -> { + firstEntered.countDown(); + await(releaseFirst); + return "first"; + })); + assertTrue(firstEntered.await(5, TimeUnit.SECONDS)); + + Future second = executor.submit(() -> { + secondStarted.countDown(); + return registry.withJobLock(jobId, () -> { + secondEntered.countDown(); + return "second"; + }); + }); + + assertTrue(secondStarted.await(5, TimeUnit.SECONDS)); + assertFalse(secondEntered.await(200, TimeUnit.MILLISECONDS)); + releaseFirst.countDown(); + + assertEquals("first", first.get(5, TimeUnit.SECONDS)); + assertEquals("second", second.get(5, TimeUnit.SECONDS)); + assertTrue(secondEntered.await(5, TimeUnit.SECONDS)); + } finally { + releaseFirst.countDown(); + executor.shutdownNow(); + assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS)); + } + } + + @Test + void actionFailureReleasesStripeForFollowingWork() { + ArtifactLifecycleLockRegistry registry = new ArtifactLifecycleLockRegistry(1); + UUID jobId = UUID.randomUUID(); + IllegalStateException expected = new IllegalStateException("controlled failure"); + + IllegalStateException actual = assertThrows( + IllegalStateException.class, + () -> registry.withJobLock(jobId, () -> { + throw expected; + }) + ); + + assertSame(expected, actual); + assertEquals("recovered", registry.withJobLock(jobId, () -> "recovered")); + } + + @Test + void invalidConstructionAndNullInputsFailFast() { + assertThrows(IllegalArgumentException.class, () -> new ArtifactLifecycleLockRegistry(0)); + ArtifactLifecycleLockRegistry defaultRegistry = new ArtifactLifecycleLockRegistry(); + ArtifactLifecycleLockRegistry registry = new ArtifactLifecycleLockRegistry(2); + + assertEquals("default", defaultRegistry.withJobLock(UUID.randomUUID(), () -> "default")); + assertThrows(NullPointerException.class, () -> registry.withJobLock(null, () -> "unused")); + assertThrows(NullPointerException.class, () -> registry.withJobLock(UUID.randomUUID(), null)); + assertSame(ArtifactLifecycleLockRegistry.shared(), ArtifactLifecycleLockRegistry.shared()); + } + + private static void await(CountDownLatch latch) { + try { + if (!latch.await(5, TimeUnit.SECONDS)) { + throw new IllegalStateException("latch timeout"); + } + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("interrupted", exception); + } + } +}