From a66a5c971bb5e49221083aac9966311dd6396b6c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 01:35:24 +0900 Subject: [PATCH 1/5] test(lifecycle): require bounded job serialization --- .../ArtifactLifecycleLockRegistryTest.java | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 src/test/java/com/clearfolio/viewer/lifecycle/ArtifactLifecycleLockRegistryTest.java 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); + } + } +} From 914b7e566a41766d05caca425f767243482240fb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 01:50:49 +0900 Subject: [PATCH 2/5] fix(lifecycle): add bounded artifact job lock registry --- .../ArtifactLifecycleLockRegistry.java | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 src/main/java/com/clearfolio/viewer/lifecycle/ArtifactLifecycleLockRegistry.java 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(); + } + } +} From 994c4462def1e66ab80e57cc15d24a5cb2617f74 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 06:25:00 +0900 Subject: [PATCH 3/5] test(reliability): require one lifecycle lock registry --- .../ArtifactLifecycleLockRegistryTest.java | 30 ++++++++++++++----- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactLifecycleLockRegistryTest.java b/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactLifecycleLockRegistryTest.java index c09be637a..0de0c7431 100644 --- a/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactLifecycleLockRegistryTest.java +++ b/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactLifecycleLockRegistryTest.java @@ -12,6 +12,7 @@ import java.util.concurrent.Executors; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.Test; @@ -22,12 +23,13 @@ class ArtifactLifecycleLockRegistryTest { @Test void sameJobCannotEnterTwoArtifactLifecyclesConcurrently() throws Exception { - ArtifactLifecycleLockRegistry registry = new ArtifactLifecycleLockRegistry(8); + ArtifactLifecycleLockRegistry registry = ArtifactLifecycleLockRegistry.shared(); 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); + AtomicReference secondThread = new AtomicReference<>(); ExecutorService executor = Executors.newFixedThreadPool(2); try { Future first = executor.submit(() -> registry.withJobLock(jobId, () -> { @@ -38,6 +40,7 @@ void sameJobCannotEnterTwoArtifactLifecyclesConcurrently() throws Exception { assertTrue(firstEntered.await(5, TimeUnit.SECONDS)); Future second = executor.submit(() -> { + secondThread.set(Thread.currentThread()); secondStarted.countDown(); return registry.withJobLock(jobId, () -> { secondEntered.countDown(); @@ -46,6 +49,7 @@ void sameJobCannotEnterTwoArtifactLifecyclesConcurrently() throws Exception { }); assertTrue(secondStarted.await(5, TimeUnit.SECONDS)); + assertTrue(awaitBlockedOnLock(secondThread.get())); assertFalse(secondEntered.await(200, TimeUnit.MILLISECONDS)); releaseFirst.countDown(); @@ -61,7 +65,7 @@ void sameJobCannotEnterTwoArtifactLifecyclesConcurrently() throws Exception { @Test void actionFailureReleasesStripeForFollowingWork() { - ArtifactLifecycleLockRegistry registry = new ArtifactLifecycleLockRegistry(1); + ArtifactLifecycleLockRegistry registry = ArtifactLifecycleLockRegistry.shared(); UUID jobId = UUID.randomUUID(); IllegalStateException expected = new IllegalStateException("controlled failure"); @@ -77,15 +81,25 @@ void actionFailureReleasesStripeForFollowingWork() { } @Test - void invalidConstructionAndNullInputsFailFast() { - assertThrows(IllegalArgumentException.class, () -> new ArtifactLifecycleLockRegistry(0)); - ArtifactLifecycleLockRegistry defaultRegistry = new ArtifactLifecycleLockRegistry(); - ArtifactLifecycleLockRegistry registry = new ArtifactLifecycleLockRegistry(2); + void constructionAndNullInputsFailClosed() { + ArtifactLifecycleLockRegistry registry = ArtifactLifecycleLockRegistry.shared(); - assertEquals("default", defaultRegistry.withJobLock(UUID.randomUUID(), () -> "default")); + assertEquals(0, ArtifactLifecycleLockRegistry.class.getConstructors().length); + assertSame(registry, ArtifactLifecycleLockRegistry.shared()); assertThrows(NullPointerException.class, () -> registry.withJobLock(null, () -> "unused")); assertThrows(NullPointerException.class, () -> registry.withJobLock(UUID.randomUUID(), null)); - assertSame(ArtifactLifecycleLockRegistry.shared(), ArtifactLifecycleLockRegistry.shared()); + } + + private static boolean awaitBlockedOnLock(Thread thread) { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (System.nanoTime() < deadline) { + Thread.State state = thread.getState(); + if (state == Thread.State.WAITING || state == Thread.State.BLOCKED) { + return true; + } + Thread.onSpinWait(); + } + return false; } private static void await(CountDownLatch latch) { From d5069a3212007166fc40cf8ecfb4e59bfe5e0a42 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 23:08:48 +0900 Subject: [PATCH 4/5] test(lifecycle): require one registry construction path --- .../viewer/lifecycle/ArtifactLifecycleLockRegistryTest.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactLifecycleLockRegistryTest.java b/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactLifecycleLockRegistryTest.java index 0de0c7431..8e9f00dd4 100644 --- a/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactLifecycleLockRegistryTest.java +++ b/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactLifecycleLockRegistryTest.java @@ -85,6 +85,8 @@ void constructionAndNullInputsFailClosed() { ArtifactLifecycleLockRegistry registry = ArtifactLifecycleLockRegistry.shared(); assertEquals(0, ArtifactLifecycleLockRegistry.class.getConstructors().length); + assertFalse(ArtifactLifecycleLockRegistry.class.isAnnotationPresent( + org.springframework.stereotype.Component.class)); assertSame(registry, ArtifactLifecycleLockRegistry.shared()); assertThrows(NullPointerException.class, () -> registry.withJobLock(null, () -> "unused")); assertThrows(NullPointerException.class, () -> registry.withJobLock(UUID.randomUUID(), null)); From d7e3e6e7b54bdc70a05f18ff09338b9ab54aaeed Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 16:56:28 +0900 Subject: [PATCH 5/5] fix(lifecycle): enforce one process-wide lock registry --- .../ArtifactLifecycleLockRegistry.java | 26 ++++++------------- 1 file changed, 8 insertions(+), 18 deletions(-) diff --git a/src/main/java/com/clearfolio/viewer/lifecycle/ArtifactLifecycleLockRegistry.java b/src/main/java/com/clearfolio/viewer/lifecycle/ArtifactLifecycleLockRegistry.java index 8bb61c263..20f09bd10 100644 --- a/src/main/java/com/clearfolio/viewer/lifecycle/ArtifactLifecycleLockRegistry.java +++ b/src/main/java/com/clearfolio/viewer/lifecycle/ArtifactLifecycleLockRegistry.java @@ -5,8 +5,6 @@ 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. * @@ -20,34 +18,26 @@ * 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); + new ArtifactLifecycleLockRegistry(); 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++) { + private ArtifactLifecycleLockRegistry() { + this.locks = new ReentrantLock[DEFAULT_STRIPE_COUNT]; + for (int index = 0; index < locks.length; index++) { locks[index] = new ReentrantLock(); } } /** - * Returns the process-wide registry used by standalone constructors. + * Returns the sole process-wide registry. + * + *

A single construction path prevents Spring injection and standalone + * callers from creating disjoint lock domains for the same job identifier.

* * @return shared fixed-memory lifecycle lock registry */