Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ repository on GitHub.
==== Bug Fixes

* Make `ConsoleLauncher` compatible with JDK 26 by avoiding final field mutations.
* Fix a concurrency bug in `NamespacedHierarchicalStore.computeIfAbsent` where
the `defaultCreator` function was executed while holding the store's internal
map lock. Under parallel execution, this could cause threads using the store to
block each other and temporarily see a missing or incorrectly initialized state
for values created via `computeIfAbsent`. The method now evaluates
`defaultCreator` outside the critical section using a memoizing supplier,
aligning its behavior with the deprecated `getOrComputeIfAbsent`.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you write this down more concisely? The release notes generally focus on a top-line understanding of what was fixed. You could express this as having solved the symptoms of #5209 rather than its root cause.

You could for clarity also add a second item that describes how computeIfAbsent no longer deadlocks.


[[v6.0.2-junit-platform-deprecations-and-breaking-changes]]
==== Deprecations and Breaking Changes
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -237,26 +237,30 @@ public void close() {
* closed
* @since 6.0
*/
@SuppressWarnings("ReferenceEquality")
@API(status = MAINTAINED, since = "6.0")
public <K, V> Object computeIfAbsent(N namespace, K key, Function<? super K, ? extends V> defaultCreator) {
Preconditions.notNull(defaultCreator, "defaultCreator must not be null");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Preconditions.notNull(defaultCreator, "defaultCreator must not be null");
notNull(defaultCreator, MANDATORY_DEFAULT_CREATOR);

imho, useless coupling, context and noisy burden.

Copy link
Author

@martinfrancois martinfrancois Dec 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wanted to do this (Preconditions.notNull => notNull) too, but since it was like this in the code already I wasn't sure if it was a convention here, as I imagine it could potentially be confused with requireNonNull or it could be intentional to make it clear it's a precondition. Could a maintainer please give a second opinion here? I'd be glad to change it.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We generally use Preconditions.notNull.

CompositeKey<N> 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) {
Copy link
Contributor

@mpkorstanje mpkorstanje Dec 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In your analysis you said:

In NamespacedHierarchicalStore#computeIfAbsent, the implementation previously relied on ConcurrentMap.computeIfAbsent, which provides the "one logical initialization per key" behavior. After the change to storedValues.compute(…), every call to NamespacedHierarchicalStore.computeIfAbsent for the same key can rerun the initialization logic and replace the existing StoredValue.

That means that even though each compute call is atomic, two threads calling NamespacedHierarchicalStore.computeIfAbsent for the same key can:

  1. Have Thread A initialize the stored value and start tracking statistics.
  2. Then have Thread B rerun the initialization and replace that value, effectively resetting the statistics.

But looking at the existing implementation, the defaultCreator is not applied until after the oldStoredValue has been checked. So when defaultCreator is applied for a given key a value was either not set at all or that value was set and set not null. So on the face of it the defaultCreator should be applied at most once and point 2 shouldn't happen.

rejectIfClosed();
var computedValue = Preconditions.notNull(defaultCreator.apply(key),
"defaultCreator must not return null");
return newStoredValue(() -> {
StoredValue newStoredValue = storedValues.compute(compositeKey, (__, oldStoredValue) -> {
if (oldStoredValue == null || oldStoredValue == storedValue) {
return newStoredValue(new MemoizingSupplier(() -> {
rejectIfClosed();
return computedValue;
});
return Preconditions.notNull(defaultCreator.apply(key), "defaultCreator must not return null");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
return Preconditions.notNull(defaultCreator.apply(key), "defaultCreator must not return null");
return notNull(defaultCreator.apply(key), MANDATORY_DEFAULT_CREATOR_VALUE);

}));
}
return oldStoredValue;
});
return requireNonNull(newStoredValue.evaluate());
try {
return requireNonNull(newStoredValue.evaluate());
}
catch (Throwable t) {
storedValues.remove(compositeKey, newStoredValue);
Copy link
Contributor

@mpkorstanje mpkorstanje Dec 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a period of time between storedValues.compute() and storedValues.remove() where a different thread via getStoredValue() can briefly access the newStoredValue and encounter its stored exception. As such the stores operations are not atomic.

And I think this invalidates any approach that tries to avoid execution of the defaultCreator outside the compute method.

throw t;
}
}
return result;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@
import static org.mockito.Mockito.verifyNoMoreInteractions;

import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Function;

Expand Down Expand Up @@ -416,6 +419,115 @@ void simulateRaceConditionInComputeIfAbsent() throws Exception {
assertEquals(1, counter.get());
assertThat(values).hasSize(threads).containsOnly(1);
}

@SuppressWarnings("deprecation")
@Test
void getOrComputeIfAbsentDoesNotDeadlockWithCollidingKeys() throws Exception {
try (var localStore = new NamespacedHierarchicalStore<String>(null)) {
var firstComputationStarted = new CountDownLatch(1);
var secondComputationAllowedToFinish = new CountDownLatch(1);
var firstThreadTimedOut = new AtomicBoolean(false);

Thread first = new Thread(
() -> localStore.getOrComputeIfAbsent(namespace, new CollidingKey("k1"), __ -> {
firstComputationStarted.countDown();
try {
if (!secondComputationAllowedToFinish.await(200, TimeUnit.MILLISECONDS)) {
firstThreadTimedOut.set(true);
}
}
catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return "value1";
}));

Thread second = new Thread(() -> {
try {
firstComputationStarted.await();
}
catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
localStore.getOrComputeIfAbsent(namespace, new CollidingKey("k2"), __ -> {
secondComputationAllowedToFinish.countDown();
return "value2";
});
});

first.start();
second.start();

first.join(1000);
second.join(1000);

assertThat(firstThreadTimedOut).as(
"getOrComputeIfAbsent should not block subsequent computations on colliding keys").isFalse();
}
}

@Test
void computeIfAbsentCanDeadlockWithCollidingKeys() throws Exception {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The naming of this test suggests that computeIfAbsent can currently deadlock. But I assume that after your fix this is no longer the case?

try (var localStore = new NamespacedHierarchicalStore<String>(null)) {
var firstComputationStarted = new CountDownLatch(1);
var secondComputationAllowedToFinish = new CountDownLatch(1);
var firstThreadTimedOut = new AtomicBoolean(false);

Thread first = new Thread(() -> localStore.computeIfAbsent(namespace, new CollidingKey("k1"), __ -> {
firstComputationStarted.countDown();
try {
if (!secondComputationAllowedToFinish.await(200, TimeUnit.MILLISECONDS)) {
firstThreadTimedOut.set(true);
}
}
catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return "value1";
}));

Thread second = new Thread(() -> {
try {
firstComputationStarted.await();
}
catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
localStore.computeIfAbsent(namespace, new CollidingKey("k2"), __ -> {
secondComputationAllowedToFinish.countDown();
return "value2";
});
});

first.start();
second.start();

first.join(1000);
second.join(1000);

assertThat(firstThreadTimedOut).as(
"computeIfAbsent should not block subsequent computations on colliding keys").isFalse();
}
}

@Test
void computeIfAbsentOverridesParentNullValue() {
// computeIfAbsent must treat a null value from the parent store as logically absent,
// so the child store can install and keep its own non-null value for the same key.
try (var parent = new NamespacedHierarchicalStore<String>(null);
var child = new NamespacedHierarchicalStore<String>(parent)) {

parent.put(namespace, key, null);

assertNull(parent.get(namespace, key));
assertNull(child.get(namespace, key));

Object childValue = child.computeIfAbsent(namespace, key, __ -> "value");

assertEquals("value", childValue);
assertEquals("value", child.get(namespace, key));
}
}
}

@Nested
Expand Down Expand Up @@ -663,6 +775,36 @@ private void assertClosed() {

}

private static final class CollidingKey {

private final String value;

private CollidingKey(String value) {
this.value = value;
}

@Override
public int hashCode() {
return 42;
}

@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (!(obj instanceof CollidingKey other)) {
return false;
}
return this.value.equals(other.value);
}

@Override
public String toString() {
return this.value;
}
}

private static Object createObject(String display) {
return new Object() {

Expand Down