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
*/