diff --git a/common/src/main/java/org/implab/gradle/common/core/lang/ReplayableQueue.java b/common/src/main/java/org/implab/gradle/common/core/lang/ReplayableQueue.java
new file mode 100644
--- /dev/null
+++ b/common/src/main/java/org/implab/gradle/common/core/lang/ReplayableQueue.java
@@ -0,0 +1,89 @@
+package org.implab.gradle.common.core.lang;
+
+import java.util.LinkedList;
+import java.util.List;
+import java.util.function.Consumer;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * Replayable append-only notification queue.
+ *
+ *
+ * The queue stores values in insertion order and supports replaying all values
+ * that were already added when a consumer is registered. The same consumer is
+ * also retained and invoked for every value added later.
+ *
+ *
+ * Values are committed before dispatch. If a consumer throws while a value is
+ * being added, the value remains recorded in the queue and will be visible to
+ * later consumers through {@link #forEach(Consumer)} and {@link #values()}.
+ *
+ *
+ * Reentrant replay is intentionally not supported. Consumers must not call
+ * {@link #add(Object)} or {@link #forEach(Consumer)} on the same queue while
+ * they are being invoked by that queue. Such nested calls fail fast with
+ * {@link IllegalStateException}. This keeps ordering predictable and avoids
+ * recursive event-loop semantics.
+ *
+ * @param value type
+ */
+@NonNullByDefault
+public class ReplayableQueue {
+ private final List> consumers = new LinkedList<>();
+ private final List values = new LinkedList<>();
+ private boolean replaying = false;
+
+ /**
+ * Adds a value and dispatches it to all registered consumers.
+ *
+ *
+ * The value is recorded before consumers are invoked. If dispatch fails, the
+ * value still belongs to the queue.
+ *
+ * @param value value to add
+ */
+ public void add(T value) {
+ safeInvoke(value, v -> {
+ values.add(v);
+ consumers.forEach(consumer -> consumer.accept(v));
+ });
+ }
+
+ /**
+ * Returns an immutable snapshot of values recorded so far.
+ *
+ * @return current values in insertion order
+ */
+ public List values() {
+ return List.copyOf(values);
+ }
+
+ /**
+ * Replays all recorded values to the consumer and registers it for future
+ * values.
+ *
+ *
+ * The consumer is registered only after replaying existing values succeeds.
+ *
+ * @param consumer consumer to replay and retain
+ */
+ public void forEach(Consumer super T> consumer) {
+ safeInvoke(values, v -> {
+ v.forEach(consumer);
+ consumers.add(consumer);
+ });
+ }
+
+ private void safeInvoke(X value, Consumer super X> consumer) {
+ if (replaying)
+ throw new IllegalStateException("Reentrant replay is not supported: replay is in progress");
+ try {
+ replaying = true;
+ consumer.accept(value);
+ } finally {
+ replaying = false;
+ }
+ }
+
+}
diff --git a/common/src/test/java/org/implab/gradle/common/core/lang/ReplayableQueueTest.java b/common/src/test/java/org/implab/gradle/common/core/lang/ReplayableQueueTest.java
new file mode 100644
--- /dev/null
+++ b/common/src/test/java/org/implab/gradle/common/core/lang/ReplayableQueueTest.java
@@ -0,0 +1,59 @@
+package org.implab.gradle.common.core.lang;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+
+class ReplayableQueueTest {
+ @Test
+ void replaysExistingValuesAndReceivesFutureValues() {
+ var queue = new ReplayableQueue();
+ var seen = new ArrayList();
+
+ queue.add("one");
+ queue.add("two");
+ queue.forEach(seen::add);
+ queue.add("three");
+
+ assertEquals(List.of("one", "two", "three"), seen);
+ assertEquals(List.of("one", "two", "three"), queue.values());
+ }
+
+ @Test
+ void commitsValueBeforeDispatchingConsumers() {
+ var queue = new ReplayableQueue();
+
+ queue.forEach(value -> {
+ throw new IllegalStateException("boom");
+ });
+
+ assertThrows(IllegalStateException.class, () -> queue.add("one"));
+ assertEquals(List.of("one"), queue.values());
+ }
+
+ @Test
+ void rejectsReentrantAdd() {
+ var queue = new ReplayableQueue();
+
+ queue.forEach(value -> queue.add("nested"));
+
+ var ex = assertThrows(IllegalStateException.class, () -> queue.add("one"));
+ assertTrue(ex.getMessage().contains("Reentrant replay is not supported"));
+ }
+
+ @Test
+ void rejectsReentrantForEach() {
+ var queue = new ReplayableQueue();
+ queue.add("one");
+
+ var ex = assertThrows(IllegalStateException.class, () -> queue.forEach(value -> queue.forEach(nested -> {
+ })));
+
+ assertTrue(ex.getMessage().contains("Reentrant replay is not supported"));
+ }
+}
diff --git a/variants/src/main/java/org/implab/gradle/internal/ReplayableQueue.java b/variants/src/main/java/org/implab/gradle/internal/ReplayableQueue.java
deleted file mode 100644
--- a/variants/src/main/java/org/implab/gradle/internal/ReplayableQueue.java
+++ /dev/null
@@ -1,27 +0,0 @@
-package org.implab.gradle.internal;
-
-import java.util.LinkedList;
-import java.util.List;
-import java.util.function.Consumer;
-
-import org.eclipse.jdt.annotation.NonNullByDefault;
-
-@NonNullByDefault
-public class ReplayableQueue {
- private final List> consumers = new LinkedList<>();
- private final List values = new LinkedList<>();
-
- public void add(T value) {
- consumers.forEach(consumer -> consumer.accept(value));
- values.add(value);
- }
-
- List values() {
- return List.copyOf(values);
- }
-
- public void forEach(Consumer super T> consumer) {
- values.forEach(consumer);
- consumers.add(consumer);
- }
-}
\ No newline at end of file
diff --git a/variants/src/main/java/org/implab/gradle/variants/artifacts/internal/ArtifactAssemblyRegistry.java b/variants/src/main/java/org/implab/gradle/variants/artifacts/internal/ArtifactAssemblyRegistry.java
--- a/variants/src/main/java/org/implab/gradle/variants/artifacts/internal/ArtifactAssemblyRegistry.java
+++ b/variants/src/main/java/org/implab/gradle/variants/artifacts/internal/ArtifactAssemblyRegistry.java
@@ -13,7 +13,7 @@ import org.gradle.api.file.FileSystemLoc
import org.gradle.api.provider.Provider;
import org.gradle.api.tasks.TaskProvider;
import org.implab.gradle.common.core.lang.Deferred;
-import org.implab.gradle.internal.ReplayableQueue;
+import org.implab.gradle.common.core.lang.ReplayableQueue;
import org.implab.gradle.variants.artifacts.ArtifactAssemblies;
import org.implab.gradle.variants.artifacts.ArtifactAssembly;
import org.implab.gradle.variants.artifacts.ArtifactSlot;
diff --git a/variants/src/main/java/org/implab/gradle/variants/artifacts/internal/OutgoingRegistry.java b/variants/src/main/java/org/implab/gradle/variants/artifacts/internal/OutgoingRegistry.java
--- a/variants/src/main/java/org/implab/gradle/variants/artifacts/internal/OutgoingRegistry.java
+++ b/variants/src/main/java/org/implab/gradle/variants/artifacts/internal/OutgoingRegistry.java
@@ -15,14 +15,16 @@ import org.gradle.api.artifacts.Configur
import org.gradle.api.model.ObjectFactory;
import org.gradle.api.provider.Property;
import org.implab.gradle.internal.IdentityContainerFactory;
-import org.implab.gradle.internal.ReplayableQueue;
+import org.implab.gradle.common.core.lang.ReplayableQueue;
import org.implab.gradle.variants.artifacts.OutgoingVariant;
import org.implab.gradle.variants.artifacts.Slot;
import org.implab.gradle.variants.core.Variant;
/**
- * Реестр исходящих вариантов. Связывает исходящие конфигурации с вариантами
- * сборки. Связь устанавливается 1:1.
+ * Registry of variant-level outgoing models.
+ *
+ * Each declared outgoing model owns one lazy Gradle consumable configuration
+ * and one live slot identity container.
*/
@NonNullByDefault
public class OutgoingRegistry {
@@ -66,9 +68,9 @@ public class OutgoingRegistry {
}
/**
- * Replayable hook which is applied when an outgoing variant is defined
+ * Registers a replayable hook for outgoing variant declarations.
*
- * @param action
+ * @param action outgoing variant action
*/
public void configureEach(Consumer super OutgoingVariant> action) {
outgoingVariants.forEach(action);
diff --git a/variants/src/main/java/org/implab/gradle/variants/sources/internal/SourceSetConfigurationRegistry.java b/variants/src/main/java/org/implab/gradle/variants/sources/internal/SourceSetConfigurationRegistry.java
--- a/variants/src/main/java/org/implab/gradle/variants/sources/internal/SourceSetConfigurationRegistry.java
+++ b/variants/src/main/java/org/implab/gradle/variants/sources/internal/SourceSetConfigurationRegistry.java
@@ -2,10 +2,8 @@ package org.implab.gradle.variants.sourc
import java.text.MessageFormat;
import java.util.LinkedHashMap;
-import java.util.LinkedList;
import java.util.List;
import java.util.Map;
-import java.util.function.Consumer;
import java.util.function.Supplier;
import java.util.stream.Collectors;
@@ -14,6 +12,7 @@ import org.gradle.api.Action;
import org.gradle.api.Named;
import org.gradle.api.logging.Logger;
import org.gradle.api.logging.Logging;
+import org.implab.gradle.common.core.lang.ReplayableQueue;
import org.implab.gradle.variants.core.Layer;
import org.implab.gradle.variants.core.Variant;
import org.implab.gradle.variants.sources.CompileUnit;
@@ -57,7 +56,7 @@ public class SourceSetConfigurationRegis
sourcesByUnit.computeIfAbsent(unit, key -> new ReplayableQueue<>()),
action,
MessageFormat.format(
- "Source set for [variant={0}, layer={1}] already materialed",
+ "Source set for [variant={0}, layer={1}] already materialized",
unit.variant().getName(),
unit.layer().getName()));
}
@@ -92,23 +91,4 @@ public class SourceSetConfigurationRegis
sourcesByLayer.computeIfAbsent(unit.layer(), key -> new ReplayableQueue<>()).add(sourceSet);
sourcesByUnit.computeIfAbsent(unit, key -> new ReplayableQueue<>()).add(sourceSet);
}
-
- class ReplayableQueue {
- private final List> consumers = new LinkedList<>();
- private final List values = new LinkedList<>();
-
- public void add(T value) {
- consumers.forEach(consumer -> consumer.accept(value));
- values.add(value);
- }
-
- List values() {
- return List.copyOf(values);
- }
-
- public void forEach(Consumer super T> consumer) {
- values.forEach(consumer);
- consumers.add(consumer);
- }
- }
}