-
-
Notifications
You must be signed in to change notification settings - Fork 1.6k
Fix concurrency bug in NamespacedHierarchicalStore.computeIfAbsent
#5209
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -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"); | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
imho, useless coupling, context and noisy burden.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I wanted to do this (
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We generally use |
||||||
| 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) { | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. In your analysis you said:
But looking at the existing implementation, the |
||||||
| 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) { | ||||||
martinfrancois marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||
| return newStoredValue(new MemoizingSupplier(() -> { | ||||||
| rejectIfClosed(); | ||||||
| return computedValue; | ||||||
| }); | ||||||
| return Preconditions.notNull(defaultCreator.apply(key), "defaultCreator must not return null"); | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
| })); | ||||||
| } | ||||||
| return oldStoredValue; | ||||||
| }); | ||||||
| return requireNonNull(newStoredValue.evaluate()); | ||||||
| try { | ||||||
| return requireNonNull(newStoredValue.evaluate()); | ||||||
| } | ||||||
| catch (Throwable t) { | ||||||
| storedValues.remove(compositeKey, newStoredValue); | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There is a period of time between And I think this invalidates any approach that tries to avoid execution of the |
||||||
| throw t; | ||||||
| } | ||||||
| } | ||||||
| return result; | ||||||
| } | ||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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; | ||
|
|
||
|
|
@@ -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 { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The naming of this test suggests that |
||
| 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 | ||
|
|
@@ -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() { | ||
|
|
||
|
|
||
There was a problem hiding this comment.
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
computeIfAbsentno longer deadlocks.