From 3fdd74c99046f3d31a21ed6960b1b7ba01f8d400 Mon Sep 17 00:00:00 2001 From: "M.P. Korstanje" Date: Mon, 15 Dec 2025 13:45:14 +0100 Subject: [PATCH 01/14] Test recursive updates and compute interactions Co-authored-by: martinfrancois --- .../NamespacedHierarchicalStoreTests.java | 94 ++++++++++++++++--- 1 file changed, 82 insertions(+), 12 deletions(-) diff --git a/platform-tests/src/test/java/org/junit/platform/engine/support/store/NamespacedHierarchicalStoreTests.java b/platform-tests/src/test/java/org/junit/platform/engine/support/store/NamespacedHierarchicalStoreTests.java index a239db0cae2e..b69a17847e41 100644 --- a/platform-tests/src/test/java/org/junit/platform/engine/support/store/NamespacedHierarchicalStoreTests.java +++ b/platform-tests/src/test/java/org/junit/platform/engine/support/store/NamespacedHierarchicalStoreTests.java @@ -25,6 +25,7 @@ import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.verifyNoMoreInteractions; +import java.io.Serial; import java.util.List; import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Function; @@ -82,6 +83,13 @@ void valueIsComputedIfAbsent() { assertEquals(value, store.get(namespace, key)); } + @Test + void valueIsComputedIfNull() { + assertNull(store.put(namespace, key, null)); + assertEquals(value, store.computeIfAbsent(namespace, key, __ -> value)); + assertEquals(value, store.get(namespace, key)); + } + @SuppressWarnings("deprecation") @Test void valueIsNotComputedIfPresentLocally() { @@ -316,22 +324,42 @@ void computeIfAbsentWithTypeSafetyAndPrimitiveValueType() { @SuppressWarnings("deprecation") @Test void getOrComputeIfAbsentWithExceptionThrowingCreatorFunction() { - var e = assertThrows(RuntimeException.class, () -> store.getOrComputeIfAbsent(namespace, key, __ -> { - throw new RuntimeException("boom"); + var e = assertThrows(ComputeException.class, () -> store.getOrComputeIfAbsent(namespace, key, __ -> { + throw new ComputeException("boom"); })); - assertSame(e, assertThrows(RuntimeException.class, () -> store.get(namespace, key))); - assertSame(e, assertThrows(RuntimeException.class, () -> store.remove(namespace, key))); + assertSame(e, assertThrows(ComputeException.class, () -> store.get(namespace, key))); + assertSame(e, assertThrows(ComputeException.class, () -> store.remove(namespace, key))); } @Test void computeIfAbsentWithExceptionThrowingCreatorFunction() { - assertThrows(RuntimeException.class, () -> store.computeIfAbsent(namespace, key, __ -> { - throw new RuntimeException("boom"); + assertThrows(ComputeException.class, () -> store.computeIfAbsent(namespace, key, __ -> { + throw new ComputeException("boom"); })); assertNull(store.get(namespace, key)); assertNull(store.remove(namespace, key)); } + @SuppressWarnings("deprecation") + @Test + void getOrComputeIfAbsentDoesNotSeeComputeIfAbsentWithExceptionThrowingCreatorFunction() { + assertThrows(ComputeException.class, () -> store.computeIfAbsent(namespace, key, __ -> { + throw new ComputeException("boom"); + })); + assertNull(store.get(namespace, key)); + assertEquals(value, store.getOrComputeIfAbsent(namespace, key, __ -> value)); + } + + @SuppressWarnings("deprecation") + @Test + void computeIfAbsentSeesGetOrComputeIfAbsentWithExceptionThrowingCreatorFunction() { + assertThrows(ComputeException.class, () -> store.getOrComputeIfAbsent(namespace, key, __ -> { + throw new ComputeException("boom"); + })); + assertThrows(ComputeException.class, () -> store.get(namespace, key)); + assertThrows(ComputeException.class, () -> store.computeIfAbsent(namespace, key, __ -> value)); + } + @Test void removeWithTypeSafetyAndInvalidRequiredTypeThrowsException() { Integer key = 42; @@ -416,6 +444,35 @@ void simulateRaceConditionInComputeIfAbsent() throws Exception { assertEquals(1, counter.get()); assertThat(values).hasSize(threads).containsOnly(1); } + + @SuppressWarnings("deprecation") + @Test + void updateRecursivelyGetOrComputeIfAbsent() { + try (var localStore = new NamespacedHierarchicalStore<>(null)) { + var value = localStore.getOrComputeIfAbsent(namespace, new CollidingKey("a"), // + a -> requireNonNull(localStore.getOrComputeIfAbsent(namespace, new CollidingKey("b"), // + b -> "enigma"))); + assertEquals("enigma", value); + } + } + + @Test + void updateRecursivelyComputeIfAbsent() { + try (var localStore = new NamespacedHierarchicalStore<>(null)) { + var value = localStore.computeIfAbsent(namespace, new CollidingKey("a"), // + a -> localStore.computeIfAbsent(namespace, new CollidingKey("b"), // + b -> "enigma")); + assertEquals("enigma", value); + } + } + + private record CollidingKey(String value) { + + @Override + public int hashCode() { + return 42; + } + } } @Nested @@ -522,19 +579,19 @@ void doesNotCallCloseActionForNullValues() { @Test void doesNotCallCloseActionForValuesThatThrowExceptionsDuringCleanup() throws Throwable { store.put(namespace, "key1", "value1"); - assertThrows(RuntimeException.class, () -> store.computeIfAbsent(namespace, "key2", __ -> { - throw new RuntimeException("boom"); + assertThrows(ComputeException.class, () -> store.computeIfAbsent(namespace, "key2", __ -> { + throw new ComputeException("boom"); })); - assertThrows(RuntimeException.class, () -> store.getOrComputeIfAbsent(namespace, "key2", __ -> { - throw new RuntimeException("boom"); + assertThrows(ComputeException.class, () -> store.getOrComputeIfAbsent(namespace, "key3", __ -> { + throw new ComputeException("boom"); })); - store.put(namespace, "key3", "value3"); + store.put(namespace, "key4", "value4"); store.close(); assertClosed(); var inOrder = inOrder(closeAction); - inOrder.verify(closeAction).close(namespace, "key3", "value3"); + inOrder.verify(closeAction).close(namespace, "key4", "value4"); inOrder.verify(closeAction).close(namespace, "key1", "value1"); inOrder.verifyNoMoreInteractions(); } @@ -672,4 +729,17 @@ public String toString() { } }; } + + /** + * To avoid confusion with other Runtime exceptions that can be thrown. + */ + private static final class ComputeException extends RuntimeException { + + @Serial + private static final long serialVersionUID = 1L; + + ComputeException(String msg) { + super(msg); + } + } } From f1db73345d4ecb2dce813cfbebe6caaddf7eaec5 Mon Sep 17 00:00:00 2001 From: "M.P. Korstanje" Date: Mon, 15 Dec 2025 18:23:39 +0100 Subject: [PATCH 02/14] Test parent relationships --- .../store/NamespacedHierarchicalStoreTests.java | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/platform-tests/src/test/java/org/junit/platform/engine/support/store/NamespacedHierarchicalStoreTests.java b/platform-tests/src/test/java/org/junit/platform/engine/support/store/NamespacedHierarchicalStoreTests.java index b69a17847e41..33bc71c19495 100644 --- a/platform-tests/src/test/java/org/junit/platform/engine/support/store/NamespacedHierarchicalStoreTests.java +++ b/platform-tests/src/test/java/org/junit/platform/engine/support/store/NamespacedHierarchicalStoreTests.java @@ -478,10 +478,22 @@ public int hashCode() { @Nested class InheritedValuesTests { + @SuppressWarnings("deprecation") @Test - void valueFromParentIsVisible() { + void presentValueFromParentIsPresent() { parentStore.put(namespace, key, value); assertEquals(value, store.get(namespace, key)); + assertEquals(value, store.getOrComputeIfAbsent(namespace, key, __ -> "enigma")); + assertEquals(value, store.computeIfAbsent(namespace, key, __ -> "enigma")); + } + + @SuppressWarnings("deprecation") + @Test + void absentValueFromParentIsOverriddenByComputeIfAbsent() { + parentStore.put(namespace, key, null); + assertNull(store.get(namespace, key)); + assertNull(store.getOrComputeIfAbsent(namespace, key, __ -> value)); + assertEquals(value, store.computeIfAbsent(namespace, key, __ -> value)); } @Test From 7ed62f6dd35fbe54b093ecd445dc4a44d3372578 Mon Sep 17 00:00:00 2001 From: "M.P. Korstanje" Date: Mon, 15 Dec 2025 18:08:58 +0100 Subject: [PATCH 03/14] Support recursive updates and compute interactions Co-authored-by: martinfrancois --- .../store/NamespacedHierarchicalStore.java | 208 +++++++++++++----- 1 file changed, 147 insertions(+), 61 deletions(-) diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/store/NamespacedHierarchicalStore.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/store/NamespacedHierarchicalStore.java index baeeaed7f122..13bf862d91c3 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/store/NamespacedHierarchicalStore.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/store/NamespacedHierarchicalStore.java @@ -22,10 +22,14 @@ import java.util.ArrayList; import java.util.Comparator; import java.util.List; +import java.util.Objects; import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.FutureTask; import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.BiFunction; import java.util.function.Function; import java.util.function.Supplier; @@ -134,8 +138,8 @@ public void close() { if (this.closeAction != null) { List failures = new ArrayList<>(); this.storedValues.entrySet().stream() // - .map(e -> e.getValue().evaluateSafely(e.getKey())) // - .filter(it -> it != null && it.value != null) // + .map(e -> EvaluatedValue.createSafely(e.getKey(), e.getValue())) // + .filter(Objects::nonNull) // .sorted(EvaluatedValue.REVERSE_INSERT_ORDER) // .forEach(it -> { try { @@ -213,11 +217,22 @@ public void close() { CompositeKey compositeKey = new CompositeKey<>(namespace, key); StoredValue storedValue = getStoredValue(compositeKey); if (storedValue == null) { - storedValue = this.storedValues.computeIfAbsent(compositeKey, - __ -> newStoredValue(new MemoizingSupplier(() -> { + var newStoredValue = this.storedValues.compute(compositeKey, // + (__, oldStoredValue) -> { + if (isPresent(oldStoredValue)) { + return oldStoredValue; + } rejectIfClosed(); - return defaultCreator.apply(key); - }))); + return newStoredSuppliedNullableValue(new DeferredSupplier(() -> { + rejectIfClosed(); + return defaultCreator.apply(key); + })); + }); + + if (newStoredValue instanceof StoredValue.DeferredValue value) { + value.delegate().run(); + } + return requireNonNull(newStoredValue.evaluate()); } return storedValue.evaluate(); } @@ -247,15 +262,20 @@ public Object computeIfAbsent(N namespace, K key, Function { if (StoredValue.evaluateIfNotNull(oldStoredValue) == null) { rejectIfClosed(); - var computedValue = Preconditions.notNull(defaultCreator.apply(key), - "defaultCreator must not return null"); - return newStoredValue(() -> { + return newStoredSuppliedValue(new DeferredSupplier(() -> { rejectIfClosed(); - return computedValue; - }); + return Preconditions.notNull(defaultCreator.apply(key), "defaultCreator must not return null"); + })); } return oldStoredValue; }); + + if (newStoredValue instanceof StoredValue.DeferredOptionalValue value) { + var delegate = value.delegate(); + delegate.run(); + return requireNonNull(delegate.getOrThrow()); + } + // put or getOrComputeIfAbsent won the race return requireNonNull(newStoredValue.evaluate()); } return result; @@ -328,7 +348,7 @@ public V computeIfAbsent(N namespace, K key, Function(namespace, key), newStoredValue(() -> value)); + StoredValue oldValue = this.storedValues.put(new CompositeKey<>(namespace, key), newStoredValue(value)); return StoredValue.evaluateIfNotNull(oldValue); } @@ -372,13 +392,24 @@ public V computeIfAbsent(N namespace, K key, Function value) { - return new StoredValue(this.insertOrderSequence.getAndIncrement(), value); + private StoredValue.Value newStoredValue(@Nullable Object value) { + var sequenceNumber = insertOrderSequence.getAndIncrement(); + return new StoredValue.Value(sequenceNumber, value); + } + + private StoredValue.DeferredValue newStoredSuppliedNullableValue(DeferredSupplier supplier) { + var sequenceNumber = insertOrderSequence.getAndIncrement(); + return new StoredValue.DeferredValue(sequenceNumber, supplier); + } + + private StoredValue.DeferredOptionalValue newStoredSuppliedValue(DeferredSupplier supplier) { + var sequenceNumber = insertOrderSequence.getAndIncrement(); + return new StoredValue.DeferredOptionalValue(sequenceNumber, supplier); } private @Nullable StoredValue getStoredValue(CompositeKey compositeKey) { StoredValue storedValue = this.storedValues.get(compositeKey); - if (storedValue != null) { + if (isPresent(storedValue)) { return storedValue; } if (this.parentStore != null) { @@ -387,6 +418,10 @@ private StoredValue newStoredValue(Supplier<@Nullable Object> value) { return null; } + private static boolean isPresent(@Nullable StoredValue value) { + return value != null && value.isPresent(); + } + private @Nullable T castToRequiredType(Object key, @Nullable Object value, Class requiredType) { Preconditions.notNull(requiredType, "requiredType must not be null"); if (value == null) { @@ -425,89 +460,140 @@ private record CompositeKey(N namespace, Object key) { } - private record StoredValue(int order, Supplier<@Nullable Object> supplier) { + private interface StoredValue { - private @Nullable EvaluatedValue evaluateSafely(CompositeKey compositeKey) { - try { - return new EvaluatedValue<>(compositeKey, this.order, evaluate()); + int order(); + + @Nullable + Object evaluate(); + + boolean isPresent(); + + static @Nullable Object evaluateIfNotNull(@Nullable StoredValue value) { + return value != null ? value.evaluate() : null; + } + + record Value(int order, @Nullable Object value) implements StoredValue { + + @Override + public @Nullable Object evaluate() { + return value; } - catch (Throwable t) { - UnrecoverableExceptions.rethrowIfUnrecoverable(t); - return null; + + @Override + public boolean isPresent() { + return true; } } - private @Nullable Object evaluate() { - return this.supplier.get(); - } + record DeferredValue(int order, DeferredSupplier delegate) implements StoredValue { - static @Nullable Object evaluateIfNotNull(@Nullable StoredValue value) { - return value != null ? value.evaluate() : null; + @Override + public @Nullable Object evaluate() { + return delegate.getOrThrow(); + } + + @Override + public boolean isPresent() { + return true; + } } + record DeferredOptionalValue(int order, DeferredSupplier delegate) implements StoredValue { + + @Override + public @Nullable Object evaluate() { + return delegate.get(); + } + + @Override + public boolean isPresent() { + return evaluate() != null; + } + } } - private record EvaluatedValue(CompositeKey compositeKey, int order, @Nullable Object value) { + private record EvaluatedValue(CompositeKey compositeKey, int order, Object value) { + + private static @Nullable EvaluatedValue createSafely(CompositeKey compositeKey, StoredValue value) { + try { + var evaluatedValue = value.evaluate(); + if (evaluatedValue == null) { + return null; + } + return new EvaluatedValue<>(compositeKey, value.order(), evaluatedValue); + } + catch (Throwable t) { + UnrecoverableExceptions.rethrowIfUnrecoverable(t); + return null; + } + } private static final Comparator> REVERSE_INSERT_ORDER = comparing( (EvaluatedValue it) -> it.order).reversed(); private void close(CloseAction closeAction) throws Throwable { - if (this.value != null) { - closeAction.close(this.compositeKey.namespace, this.compositeKey.key, this.value); - } + closeAction.close(this.compositeKey.namespace, this.compositeKey.key, this.value); } } /** - * Thread-safe {@link Supplier} that memoizes the result of calling its - * delegate and ensures it is called at most once. - * - *

If the delegate throws an exception, it is stored and rethrown every - * time {@link #get()} is called. - * - * @see StoredValue + * Deferred computation that can be added to the store. + *

+ * This allows values to be computed outside the + * {@link ConcurrentHashMap#compute(Object, BiFunction)} calls and + * prevents recursive updates. */ - private static class MemoizingSupplier implements Supplier<@Nullable Object> { + private static final class DeferredSupplier implements Supplier<@Nullable Object> { - private static final Object NO_VALUE_SET = new Object(); + private final FutureTask<@Nullable Object> task; - private final Supplier<@Nullable Object> delegate; - - @Nullable - private volatile Object value = NO_VALUE_SET; + DeferredSupplier(Supplier<@Nullable Object> delegate) { + this.task = new FutureTask<>(delegate::get); + } - private MemoizingSupplier(Supplier<@Nullable Object> delegate) { - this.delegate = delegate; + void run() { + this.task.run(); } @Override public @Nullable Object get() { - if (this.value == NO_VALUE_SET) { - computeValue(); + try { + return this.task.get(); } - if (this.value instanceof Failure failure) { - throw throwAsUncheckedException(failure.throwable); + catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw throwAsUncheckedException(e); + } + catch (ExecutionException e) { + Throwable t = e.getCause(); + if (t == null) { + t = e; + } + UnrecoverableExceptions.rethrowIfUnrecoverable(t); + return null; } - return this.value; } - private synchronized void computeValue() { + @Nullable + Object getOrThrow() { try { - if (this.value == NO_VALUE_SET) { - this.value = this.delegate.get(); - } + return this.task.get(); } - catch (Throwable t) { - this.value = new Failure(t); + catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw throwAsUncheckedException(e); + } + catch (ExecutionException e) { + Throwable t = e.getCause(); + if (t == null) { + t = e; + } UnrecoverableExceptions.rethrowIfUnrecoverable(t); + throw throwAsUncheckedException(t); } } - - private record Failure(Throwable throwable) { - } - } /** From 708bb359c2f5ff7e7a74072e2b8dd116f88d851c Mon Sep 17 00:00:00 2001 From: "M.P. Korstanje" Date: Mon, 15 Dec 2025 18:10:57 +0100 Subject: [PATCH 04/14] Return early where applicable --- .../store/NamespacedHierarchicalStore.java | 70 +++++++++---------- 1 file changed, 35 insertions(+), 35 deletions(-) diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/store/NamespacedHierarchicalStore.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/store/NamespacedHierarchicalStore.java index 13bf862d91c3..b37265bf8a42 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/store/NamespacedHierarchicalStore.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/store/NamespacedHierarchicalStore.java @@ -216,25 +216,25 @@ public void close() { Preconditions.notNull(defaultCreator, "defaultCreator must not be null"); CompositeKey compositeKey = new CompositeKey<>(namespace, key); StoredValue storedValue = getStoredValue(compositeKey); - if (storedValue == null) { - var newStoredValue = this.storedValues.compute(compositeKey, // - (__, oldStoredValue) -> { - if (isPresent(oldStoredValue)) { - return oldStoredValue; - } + if (storedValue != null) { + return storedValue.evaluate(); + } + var newStoredValue = this.storedValues.compute(compositeKey, // + (__, oldStoredValue) -> { + if (isPresent(oldStoredValue)) { + return oldStoredValue; + } + rejectIfClosed(); + return newStoredSuppliedNullableValue(new DeferredSupplier(() -> { rejectIfClosed(); - return newStoredSuppliedNullableValue(new DeferredSupplier(() -> { - rejectIfClosed(); - return defaultCreator.apply(key); - })); - }); - - if (newStoredValue instanceof StoredValue.DeferredValue value) { - value.delegate().run(); - } - return requireNonNull(newStoredValue.evaluate()); + return defaultCreator.apply(key); + })); + }); + + if (newStoredValue instanceof StoredValue.DeferredValue value) { + value.delegate().run(); } - return storedValue.evaluate(); + return requireNonNull(newStoredValue.evaluate()); } /** @@ -258,27 +258,27 @@ public Object computeIfAbsent(N namespace, K key, Function compositeKey = new CompositeKey<>(namespace, key); StoredValue storedValue = getStoredValue(compositeKey); var result = StoredValue.evaluateIfNotNull(storedValue); - if (result == null) { - StoredValue newStoredValue = this.storedValues.compute(compositeKey, (__, oldStoredValue) -> { - if (StoredValue.evaluateIfNotNull(oldStoredValue) == null) { - rejectIfClosed(); - return newStoredSuppliedValue(new DeferredSupplier(() -> { - rejectIfClosed(); - return Preconditions.notNull(defaultCreator.apply(key), "defaultCreator must not return null"); - })); - } + if (result != null) { + return result; + } + StoredValue newStoredValue = this.storedValues.compute(compositeKey, (__, oldStoredValue) -> { + if (StoredValue.evaluateIfNotNull(oldStoredValue) != null) { return oldStoredValue; - }); - - if (newStoredValue instanceof StoredValue.DeferredOptionalValue value) { - var delegate = value.delegate(); - delegate.run(); - return requireNonNull(delegate.getOrThrow()); } - // put or getOrComputeIfAbsent won the race - return requireNonNull(newStoredValue.evaluate()); + rejectIfClosed(); + return newStoredSuppliedValue(new DeferredSupplier(() -> { + rejectIfClosed(); + return Preconditions.notNull(defaultCreator.apply(key), "defaultCreator must not return null"); + })); + }); + + if (newStoredValue instanceof StoredValue.DeferredOptionalValue value) { + var delegate = value.delegate(); + delegate.run(); + return requireNonNull(delegate.getOrThrow()); } - return result; + // put or getOrComputeIfAbsent won the race + return requireNonNull(newStoredValue.evaluate()); } /** From 160a4edfea8af90cce8f7406e72058eef0927788 Mon Sep 17 00:00:00 2001 From: "M.P. Korstanje" Date: Mon, 15 Dec 2025 18:32:21 +0100 Subject: [PATCH 05/14] Replace never occurring null branch with assertion --- .../store/NamespacedHierarchicalStore.java | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/store/NamespacedHierarchicalStore.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/store/NamespacedHierarchicalStore.java index b37265bf8a42..078f95349900 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/store/NamespacedHierarchicalStore.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/store/NamespacedHierarchicalStore.java @@ -567,11 +567,9 @@ void run() { throw throwAsUncheckedException(e); } catch (ExecutionException e) { - Throwable t = e.getCause(); - if (t == null) { - t = e; - } - UnrecoverableExceptions.rethrowIfUnrecoverable(t); + // non-null guaranteed by FutureTask + var cause = requireNonNull(e.getCause()); + UnrecoverableExceptions.rethrowIfUnrecoverable(cause); return null; } } @@ -586,12 +584,10 @@ Object getOrThrow() { throw throwAsUncheckedException(e); } catch (ExecutionException e) { - Throwable t = e.getCause(); - if (t == null) { - t = e; - } - UnrecoverableExceptions.rethrowIfUnrecoverable(t); - throw throwAsUncheckedException(t); + // non-null guaranteed by FutureTask + var cause = requireNonNull(e.getCause()); + UnrecoverableExceptions.rethrowIfUnrecoverable(cause); + throw throwAsUncheckedException(cause); } } } From bd2042f47dbbd7398cd96891c7ed47f4f643a370 Mon Sep 17 00:00:00 2001 From: "M.P. Korstanje" Date: Mon, 15 Dec 2025 18:40:12 +0100 Subject: [PATCH 06/14] Only allow caller owning the DeferredSupplier to run it --- .../store/NamespacedHierarchicalStore.java | 45 ++++++++++++++++--- 1 file changed, 38 insertions(+), 7 deletions(-) diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/store/NamespacedHierarchicalStore.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/store/NamespacedHierarchicalStore.java index 078f95349900..e33137c21b32 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/store/NamespacedHierarchicalStore.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/store/NamespacedHierarchicalStore.java @@ -221,7 +221,9 @@ public void close() { } var newStoredValue = this.storedValues.compute(compositeKey, // (__, oldStoredValue) -> { - if (isPresent(oldStoredValue)) { + // guard against race conditions, repeated from getStoredValue + // this filters out failures inserted by computeIfAbsent + if (isStoredValuePresent(oldStoredValue)) { return oldStoredValue; } rejectIfClosed(); @@ -232,6 +234,7 @@ public void close() { }); if (newStoredValue instanceof StoredValue.DeferredValue value) { + // Any thread that won the race may run the DeferredSupplier value.delegate().run(); } return requireNonNull(newStoredValue.evaluate()); @@ -261,12 +264,15 @@ public Object computeIfAbsent(N namespace, K key, Function { + // guard against race conditions + // computeIfAbsent replaces both null and absent values if (StoredValue.evaluateIfNotNull(oldStoredValue) != null) { return oldStoredValue; } rejectIfClosed(); - return newStoredSuppliedValue(new DeferredSupplier(() -> { + return newStoredSuppliedValue(new DeferredSupplier(ownerToken, () -> { rejectIfClosed(); return Preconditions.notNull(defaultCreator.apply(key), "defaultCreator must not return null"); })); @@ -274,10 +280,15 @@ public Object computeIfAbsent(N namespace, K key, Function compositeKey) { StoredValue storedValue = this.storedValues.get(compositeKey); - if (isPresent(storedValue)) { + if (isStoredValuePresent(storedValue)) { return storedValue; } if (this.parentStore != null) { @@ -418,7 +429,7 @@ private StoredValue.DeferredOptionalValue newStoredSuppliedValue(DeferredSupplie return null; } - private static boolean isPresent(@Nullable StoredValue value) { + private static boolean isStoredValuePresent(@Nullable StoredValue value) { return value != null && value.isPresent(); } @@ -473,6 +484,9 @@ private interface StoredValue { return value != null ? value.evaluate() : null; } + /** + * May contain {@code null} or a value, never an exception. + */ record Value(int order, @Nullable Object value) implements StoredValue { @Override @@ -486,6 +500,9 @@ public boolean isPresent() { } } + /** + * May eventually contain {@code null} or a value or an exception. + */ record DeferredValue(int order, DeferredSupplier delegate) implements StoredValue { @Override @@ -499,6 +516,9 @@ public boolean isPresent() { } } + /** + * May eventually contain a value or an exception, never {@code null}. + */ record DeferredOptionalValue(int order, DeferredSupplier delegate) implements StoredValue { @Override @@ -548,11 +568,22 @@ private void close(CloseAction closeAction) throws Throwable { private static final class DeferredSupplier implements Supplier<@Nullable Object> { private final FutureTask<@Nullable Object> task; + private final @Nullable Object ownerToken; DeferredSupplier(Supplier<@Nullable Object> delegate) { + this(null, delegate); + } + + DeferredSupplier(@Nullable Object ownerToken, Supplier<@Nullable Object> delegate) { + this.ownerToken = ownerToken; this.task = new FutureTask<>(delegate::get); } + @Nullable + Object ownerToken() { + return ownerToken; + } + void run() { this.task.run(); } From 3c5a57156aafd2647aa21fd0981f5caed9a06fc3 Mon Sep 17 00:00:00 2001 From: "M.P. Korstanje" Date: Mon, 15 Dec 2025 19:42:33 +0100 Subject: [PATCH 07/14] Test DeferredSupplier --- .../store/NamespacedHierarchicalStore.java | 2 +- .../NamespacedHierarchicalStoreTests.java | 45 +++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/store/NamespacedHierarchicalStore.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/store/NamespacedHierarchicalStore.java index e33137c21b32..8047420ebf66 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/store/NamespacedHierarchicalStore.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/store/NamespacedHierarchicalStore.java @@ -565,7 +565,7 @@ private void close(CloseAction closeAction) throws Throwable { * {@link ConcurrentHashMap#compute(Object, BiFunction)} calls and * prevents recursive updates. */ - private static final class DeferredSupplier implements Supplier<@Nullable Object> { + static final class DeferredSupplier implements Supplier<@Nullable Object> { private final FutureTask<@Nullable Object> task; private final @Nullable Object ownerToken; diff --git a/platform-tests/src/test/java/org/junit/platform/engine/support/store/NamespacedHierarchicalStoreTests.java b/platform-tests/src/test/java/org/junit/platform/engine/support/store/NamespacedHierarchicalStoreTests.java index 33bc71c19495..4834715aaf22 100644 --- a/platform-tests/src/test/java/org/junit/platform/engine/support/store/NamespacedHierarchicalStoreTests.java +++ b/platform-tests/src/test/java/org/junit/platform/engine/support/store/NamespacedHierarchicalStoreTests.java @@ -16,6 +16,7 @@ import static org.junit.jupiter.api.Assertions.assertNull; 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 static org.junit.platform.commons.test.ConcurrencyTestingUtils.executeConcurrently; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.inOrder; @@ -732,6 +733,46 @@ private void assertClosed() { } + @Nested + class DeferredSupplierTests { + + @Test + void getCanBeInterrupted() { + var supplier = new NamespacedHierarchicalStore.DeferredSupplier(() -> { + try { + Thread.sleep(1000); + } + catch (InterruptedException e) { + throw new ComputeException(e); + } + return value; + }); + Thread.currentThread().interrupt(); + assertThrows(InterruptedException.class, () -> { + supplier.get(); + }); + assertTrue(Thread.interrupted()); + } + + @Test + void getOrThrowCanBeInterrupted() { + var supplier = new NamespacedHierarchicalStore.DeferredSupplier(() -> { + try { + Thread.sleep(1000); + } + catch (InterruptedException e) { + throw new ComputeException(e); + } + return value; + }); + Thread.currentThread().interrupt(); + assertThrows(InterruptedException.class, () -> { + supplier.getOrThrow(); + }); + assertTrue(Thread.interrupted()); + } + } + private static Object createObject(String display) { return new Object() { @@ -753,5 +794,9 @@ private static final class ComputeException extends RuntimeException { ComputeException(String msg) { super(msg); } + + ComputeException(InterruptedException e) { + super(e); + } } } From 9598bc8195fd1803227680adb92e1d614cf60613 Mon Sep 17 00:00:00 2001 From: "M.P. Korstanje" Date: Mon, 15 Dec 2025 19:50:37 +0100 Subject: [PATCH 08/14] Clarify thread -> caller --- .../engine/support/store/NamespacedHierarchicalStore.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/store/NamespacedHierarchicalStore.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/store/NamespacedHierarchicalStore.java index 8047420ebf66..e77cc8dc6c48 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/store/NamespacedHierarchicalStore.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/store/NamespacedHierarchicalStore.java @@ -234,7 +234,7 @@ public void close() { }); if (newStoredValue instanceof StoredValue.DeferredValue value) { - // Any thread that won the race may run the DeferredSupplier + // Any caller that won the race may run the DeferredSupplier value.delegate().run(); } return requireNonNull(newStoredValue.evaluate()); @@ -281,14 +281,14 @@ public Object computeIfAbsent(N namespace, K key, Function Date: Mon, 15 Dec 2025 20:45:35 +0100 Subject: [PATCH 09/14] Test DeferredSupplier against unrecoverables --- .../NamespacedHierarchicalStoreTests.java | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/platform-tests/src/test/java/org/junit/platform/engine/support/store/NamespacedHierarchicalStoreTests.java b/platform-tests/src/test/java/org/junit/platform/engine/support/store/NamespacedHierarchicalStoreTests.java index 4834715aaf22..3e199bcbfa8c 100644 --- a/platform-tests/src/test/java/org/junit/platform/engine/support/store/NamespacedHierarchicalStoreTests.java +++ b/platform-tests/src/test/java/org/junit/platform/engine/support/store/NamespacedHierarchicalStoreTests.java @@ -754,6 +754,28 @@ void getCanBeInterrupted() { assertTrue(Thread.interrupted()); } + @Test + void getThrowsIfUnrecoverable() { + var supplier = new NamespacedHierarchicalStore.DeferredSupplier(() -> { + throw new OutOfMemoryError("boom"); + }); + supplier.run(); + assertThrows(OutOfMemoryError.class, () -> { + supplier.get(); + }); + } + + @Test + void getOrThrowThrowsIfUnrecoverable() { + var supplier = new NamespacedHierarchicalStore.DeferredSupplier(() -> { + throw new OutOfMemoryError("boom"); + }); + supplier.run(); + assertThrows(OutOfMemoryError.class, () -> { + supplier.getOrThrow(); + }); + } + @Test void getOrThrowCanBeInterrupted() { var supplier = new NamespacedHierarchicalStore.DeferredSupplier(() -> { From 90762dd11d87e315ea0697f95ff188f9449477a2 Mon Sep 17 00:00:00 2001 From: "M.P. Korstanje" Date: Mon, 15 Dec 2025 21:14:02 +0100 Subject: [PATCH 10/14] Naming things --- .../support/store/NamespacedHierarchicalStore.java | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/store/NamespacedHierarchicalStore.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/store/NamespacedHierarchicalStore.java index e77cc8dc6c48..993d39c3de44 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/store/NamespacedHierarchicalStore.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/store/NamespacedHierarchicalStore.java @@ -223,7 +223,7 @@ public void close() { (__, oldStoredValue) -> { // guard against race conditions, repeated from getStoredValue // this filters out failures inserted by computeIfAbsent - if (isStoredValuePresent(oldStoredValue)) { + if (StoredValue.isNonNullAndPresent(oldStoredValue)) { return oldStoredValue; } rejectIfClosed(); @@ -420,7 +420,7 @@ private StoredValue.DeferredOptionalValue newStoredSuppliedValue(DeferredSupplie private @Nullable StoredValue getStoredValue(CompositeKey compositeKey) { StoredValue storedValue = this.storedValues.get(compositeKey); - if (isStoredValuePresent(storedValue)) { + if (StoredValue.isNonNullAndPresent(storedValue)) { return storedValue; } if (this.parentStore != null) { @@ -429,10 +429,6 @@ private StoredValue.DeferredOptionalValue newStoredSuppliedValue(DeferredSupplie return null; } - private static boolean isStoredValuePresent(@Nullable StoredValue value) { - return value != null && value.isPresent(); - } - private @Nullable T castToRequiredType(Object key, @Nullable Object value, Class requiredType) { Preconditions.notNull(requiredType, "requiredType must not be null"); if (value == null) { @@ -484,6 +480,10 @@ private interface StoredValue { return value != null ? value.evaluate() : null; } + static boolean isNonNullAndPresent(@Nullable StoredValue value) { + return value != null && value.isPresent(); + } + /** * May contain {@code null} or a value, never an exception. */ From 2ab10b557d1b192e5157d80086fb2cd41b9a6cd6 Mon Sep 17 00:00:00 2001 From: "M.P. Korstanje" Date: Tue, 16 Dec 2025 00:40:12 +0100 Subject: [PATCH 11/14] Hide DeferredSupplier implementation details from StoredValue users --- .../store/NamespacedHierarchicalStore.java | 128 +++++++++++------- 1 file changed, 82 insertions(+), 46 deletions(-) diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/store/NamespacedHierarchicalStore.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/store/NamespacedHierarchicalStore.java index 993d39c3de44..9721b67779d7 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/store/NamespacedHierarchicalStore.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/store/NamespacedHierarchicalStore.java @@ -219,6 +219,10 @@ public void close() { if (storedValue != null) { return storedValue.evaluate(); } + var candidateStoredValue = newStoredSuppliedNullableValue(() -> { + rejectIfClosed(); + return defaultCreator.apply(key); + }); var newStoredValue = this.storedValues.compute(compositeKey, // (__, oldStoredValue) -> { // guard against race conditions, repeated from getStoredValue @@ -227,15 +231,12 @@ public void close() { return oldStoredValue; } rejectIfClosed(); - return newStoredSuppliedNullableValue(new DeferredSupplier(() -> { - rejectIfClosed(); - return defaultCreator.apply(key); - })); + return candidateStoredValue; }); - if (newStoredValue instanceof StoredValue.DeferredValue value) { - // Any caller that won the race may run the DeferredSupplier - value.delegate().run(); + // Only the caller that created the candidateStoredValue may run it + if (candidateStoredValue.equals(newStoredValue)) { + candidateStoredValue.run(); } return requireNonNull(newStoredValue.evaluate()); } @@ -264,31 +265,28 @@ public Object computeIfAbsent(N namespace, K key, Function { + var candidateStoredValue = newStoredSuppliedValue(() -> { + rejectIfClosed(); + return Preconditions.notNull(defaultCreator.apply(key), "defaultCreator must not return null"); + }); + var newStoredValue = this.storedValues.compute(compositeKey, (__, oldStoredValue) -> { // guard against race conditions // computeIfAbsent replaces both null and absent values if (StoredValue.evaluateIfNotNull(oldStoredValue) != null) { return oldStoredValue; } rejectIfClosed(); - return newStoredSuppliedValue(new DeferredSupplier(ownerToken, () -> { - rejectIfClosed(); - return Preconditions.notNull(defaultCreator.apply(key), "defaultCreator must not return null"); - })); + return candidateStoredValue; }); - if (newStoredValue instanceof StoredValue.DeferredOptionalValue value) { - var delegate = value.delegate(); - if (ownerToken.equals(delegate.ownerToken())) { - // Only the caller that created the DeferredSupplier may run it - // and see the exception. - delegate.run(); - return requireNonNull(delegate.getOrThrow()); - } + // Only the caller that created the candidateStoredValue may run it + // and see the exception. + if (candidateStoredValue.equals(newStoredValue)) { + candidateStoredValue.run(); + return candidateStoredValue.getOrThrow(); } - // Either put, getOrComputeIfAbsent, or another computeIfAbsent call - // put the value in the store + // In a race condition either put, getOrComputeIfAbsent, or another + // computeIfAbsent call put the value in the store return requireNonNull(newStoredValue.evaluate()); } @@ -408,12 +406,12 @@ private StoredValue.Value newStoredValue(@Nullable Object value) { return new StoredValue.Value(sequenceNumber, value); } - private StoredValue.DeferredValue newStoredSuppliedNullableValue(DeferredSupplier supplier) { + private StoredValue.DeferredValue newStoredSuppliedNullableValue(Supplier<@Nullable Object> supplier) { var sequenceNumber = insertOrderSequence.getAndIncrement(); return new StoredValue.DeferredValue(sequenceNumber, supplier); } - private StoredValue.DeferredOptionalValue newStoredSuppliedValue(DeferredSupplier supplier) { + private StoredValue.DeferredOptionalValue newStoredSuppliedValue(Supplier supplier) { var sequenceNumber = insertOrderSequence.getAndIncrement(); return new StoredValue.DeferredOptionalValue(sequenceNumber, supplier); } @@ -487,7 +485,14 @@ static boolean isNonNullAndPresent(@Nullable StoredValue value) { /** * May contain {@code null} or a value, never an exception. */ - record Value(int order, @Nullable Object value) implements StoredValue { + final class Value implements StoredValue { + private final int order; + private final @Nullable Object value; + + Value(int order, @Nullable Object value) { + this.order = order; + this.value = value; + } @Override public @Nullable Object evaluate() { @@ -498,12 +503,24 @@ record Value(int order, @Nullable Object value) implements StoredValue { public boolean isPresent() { return true; } + + @Override + public int order() { + return order; + } } /** * May eventually contain {@code null} or a value or an exception. */ - record DeferredValue(int order, DeferredSupplier delegate) implements StoredValue { + final class DeferredValue implements StoredValue { + private final int order; + private final DeferredSupplier delegate; + + DeferredValue(int order, Supplier<@Nullable Object> delegate) { + this.order = order; + this.delegate = new DeferredSupplier(delegate); + } @Override public @Nullable Object evaluate() { @@ -514,12 +531,28 @@ record DeferredValue(int order, DeferredSupplier delegate) implements StoredValu public boolean isPresent() { return true; } + + void run() { + delegate.run(); + } + + @Override + public int order() { + return order; + } } /** * May eventually contain a value or an exception, never {@code null}. */ - record DeferredOptionalValue(int order, DeferredSupplier delegate) implements StoredValue { + final class DeferredOptionalValue implements StoredValue { + private final int order; + private final DeferredSupplier delegate; + + DeferredOptionalValue(int order, Supplier delegate) { + this.order = order; + this.delegate = new DeferredSupplier(delegate); + } @Override public @Nullable Object evaluate() { @@ -530,6 +563,20 @@ record DeferredOptionalValue(int order, DeferredSupplier delegate) implements St public boolean isPresent() { return evaluate() != null; } + + void run() { + delegate.run(); + } + + Object getOrThrow() { + // Delegate does not produce null + return requireNonNull(delegate.getOrThrow()); + } + + @Override + public int order() { + return order; + } } } @@ -565,33 +612,22 @@ private void close(CloseAction closeAction) throws Throwable { * {@link ConcurrentHashMap#compute(Object, BiFunction)} calls and * prevents recursive updates. */ - static final class DeferredSupplier implements Supplier<@Nullable Object> { + static final class DeferredSupplier { private final FutureTask<@Nullable Object> task; - private final @Nullable Object ownerToken; - - DeferredSupplier(Supplier<@Nullable Object> delegate) { - this(null, delegate); - } - DeferredSupplier(@Nullable Object ownerToken, Supplier<@Nullable Object> delegate) { - this.ownerToken = ownerToken; + DeferredSupplier(Supplier delegate) { this.task = new FutureTask<>(delegate::get); } - @Nullable - Object ownerToken() { - return ownerToken; - } - void run() { - this.task.run(); + task.run(); } - @Override - public @Nullable Object get() { + @Nullable + Object get() { try { - return this.task.get(); + return task.get(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); @@ -608,7 +644,7 @@ void run() { @Nullable Object getOrThrow() { try { - return this.task.get(); + return task.get(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); From 3086e4569bc550a54718f67e20ff5c68dedd7fb6 Mon Sep 17 00:00:00 2001 From: "M.P. Korstanje" Date: Tue, 16 Dec 2025 02:54:51 +0100 Subject: [PATCH 12/14] Update CHANGELOG --- .../ROOT/partials/release-notes/release-notes-6.1.0-M2.adoc | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/documentation/modules/ROOT/partials/release-notes/release-notes-6.1.0-M2.adoc b/documentation/modules/ROOT/partials/release-notes/release-notes-6.1.0-M2.adoc index ba9b0fb0fa68..84025ff9904f 100644 --- a/documentation/modules/ROOT/partials/release-notes/release-notes-6.1.0-M2.adoc +++ b/documentation/modules/ROOT/partials/release-notes/release-notes-6.1.0-M2.adoc @@ -16,6 +16,8 @@ repository on GitHub. ==== Bug Fixes * Clarify `TestDescriptor` implementation requirements. +* Enable recursive updates when using `NamespacedHierarchicalStore.computeIfAbsent(N, K, Function)`. + This provides parity with the deprecated `NamespacedHierarchicalStore.getOrComputeIfAbsent(N, K, Function)` [[v6.1.0-M2-junit-platform-deprecations-and-breaking-changes]] ==== Deprecations and Breaking Changes @@ -34,7 +36,8 @@ repository on GitHub. [[v6.1.0-M2-junit-jupiter-bug-fixes]] ==== Bug Fixes -* ❓ +* Enable recursive updates when using `ExtensionContext.Store.computeIfAbsent(K, Function, Class)`. + This provides parity with the deprecated `ExtensionContext.Store.getOrComputeIfAbsent(K, Function, Class)` [[v6.1.0-M2-junit-jupiter-deprecations-and-breaking-changes]] ==== Deprecations and Breaking Changes From d40ae3162c13c99430bd7cddf11b51cebe609661 Mon Sep 17 00:00:00 2001 From: "M.P. Korstanje" Date: Tue, 16 Dec 2025 04:16:46 +0100 Subject: [PATCH 13/14] Touch-ups and naming things --- .../store/NamespacedHierarchicalStore.java | 42 +++++++++---------- 1 file changed, 20 insertions(+), 22 deletions(-) diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/store/NamespacedHierarchicalStore.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/store/NamespacedHierarchicalStore.java index 9721b67779d7..25dc3e86cec2 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/store/NamespacedHierarchicalStore.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/store/NamespacedHierarchicalStore.java @@ -214,16 +214,16 @@ public void close() { public @Nullable Object getOrComputeIfAbsent(N namespace, K key, Function defaultCreator) { Preconditions.notNull(defaultCreator, "defaultCreator must not be null"); - CompositeKey compositeKey = new CompositeKey<>(namespace, key); - StoredValue storedValue = getStoredValue(compositeKey); - if (storedValue != null) { - return storedValue.evaluate(); + var compositeKey = new CompositeKey<>(namespace, key); + var currentStoredValue = getStoredValue(compositeKey); + if (currentStoredValue != null) { + return currentStoredValue.evaluate(); } var candidateStoredValue = newStoredSuppliedNullableValue(() -> { rejectIfClosed(); return defaultCreator.apply(key); }); - var newStoredValue = this.storedValues.compute(compositeKey, // + var storedValue = this.storedValues.compute(compositeKey, // (__, oldStoredValue) -> { // guard against race conditions, repeated from getStoredValue // this filters out failures inserted by computeIfAbsent @@ -235,10 +235,10 @@ public void close() { }); // Only the caller that created the candidateStoredValue may run it - if (candidateStoredValue.equals(newStoredValue)) { - candidateStoredValue.run(); + if (candidateStoredValue.equals(storedValue)) { + return candidateStoredValue.execute(); } - return requireNonNull(newStoredValue.evaluate()); + return storedValue.evaluate(); } /** @@ -259,9 +259,9 @@ public void close() { @API(status = MAINTAINED, since = "6.0") public Object computeIfAbsent(N namespace, K key, Function defaultCreator) { Preconditions.notNull(defaultCreator, "defaultCreator must not be null"); - CompositeKey compositeKey = new CompositeKey<>(namespace, key); - StoredValue storedValue = getStoredValue(compositeKey); - var result = StoredValue.evaluateIfNotNull(storedValue); + var compositeKey = new CompositeKey<>(namespace, key); + var currentStoredValue = getStoredValue(compositeKey); + var result = StoredValue.evaluateIfNotNull(currentStoredValue); if (result != null) { return result; } @@ -269,7 +269,7 @@ public Object computeIfAbsent(N namespace, K key, Function { + var storedValue = storedValues.compute(compositeKey, (__, oldStoredValue) -> { // guard against race conditions // computeIfAbsent replaces both null and absent values if (StoredValue.evaluateIfNotNull(oldStoredValue) != null) { @@ -281,13 +281,12 @@ public Object computeIfAbsent(N namespace, K key, Function Date: Wed, 17 Dec 2025 18:07:59 +0100 Subject: [PATCH 14/14] Replace heavy DeferredOptionalValue with lightweight Value --- .../store/NamespacedHierarchicalStore.java | 27 ++++++++++++++----- 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/store/NamespacedHierarchicalStore.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/store/NamespacedHierarchicalStore.java index 25dc3e86cec2..8560c69ec00e 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/store/NamespacedHierarchicalStore.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/store/NamespacedHierarchicalStore.java @@ -223,7 +223,7 @@ public void close() { rejectIfClosed(); return defaultCreator.apply(key); }); - var storedValue = this.storedValues.compute(compositeKey, // + var storedValue = storedValues.compute(compositeKey, // (__, oldStoredValue) -> { // guard against race conditions, repeated from getStoredValue // this filters out failures inserted by computeIfAbsent @@ -279,14 +279,29 @@ public Object computeIfAbsent(N namespace, K key, Function BiFunction, StoredValue, StoredValue> compareAndPut(StoredValue expectedValue, + StoredValue newValue) { + return (__, storedValue) -> { + if (!expectedValue.equals(storedValue)) { + return storedValue; + } + return newValue; + }; } /**