This is an automated email from the ASF dual-hosted git repository.
garydgregory pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/commons-lang.git
The following commit(s) were added to refs/heads/master by this push:
new 1aef13126 Memoizer: default caches the first failure forever, has no
size bound or eviction, and runs the user computation inside the
ConcurrentHashMap bin lock (blocking unrelated keys, deadlocking reentrant use)
(f011).
1aef13126 is described below
commit 1aef131265a6b2c8a5ded4301dd92468c7d826c5
Author: Gary Gregory <[email protected]>
AuthorDate: Sat Sep 5 08:46:31 2026 -0400
Memoizer: default caches the first failure forever, has no size bound or
eviction, and runs the user computation inside the ConcurrentHashMap bin
lock (blocking unrelated keys, deadlocking reentrant use) (f011).
---
src/changes/changes.xml | 4 +-
.../apache/commons/lang3/concurrent/Memoizer.java | 39 +++-
.../lang3/concurrent/MemoizerCacheTest.java | 227 +++++++++++++++++++++
3 files changed, 260 insertions(+), 10 deletions(-)
diff --git a/src/changes/changes.xml b/src/changes/changes.xml
index 38bccebe6..77bbd68b2 100644
--- a/src/changes/changes.xml
+++ b/src/changes/changes.xml
@@ -259,7 +259,9 @@ java.lang.NullPointerException: Cannot invoke
<action type="fix" dev="ggregory" due-to="Gary
Gregory">LocaleUtils static caches no longer grows on invalid input to
LocaleUtils.countriesByLanguage(String) (f007).</action>
<action type="fix" dev="ggregory" due-to="Gary
Gregory">ExtendedMessageFormat.applyPattern() is quadratic: full
pattern.toCharArray() per token (f008).</action>
<action type="fix" dev="ggregory" due-to="Gary
Gregory">WordUtils.wrap(wrapLongWords=false, the 2-arg default) copies the
entire remaining string every iteration. (f009).</action>
- <action type="fix" dev="ggregory" due-to="Gary
Gregory">FastDateParser.parse throws undeclared IllegalArgumentException,
NullPointerException, and IllegalStateException on crafted date strings.
(f010).</action>
+ <action type="fix" dev="ggregory" due-to="Gary
Gregory">FastDateParser.parse throws undeclared IllegalArgumentException,
NullPointerException, and IllegalStateException on crafted date strings
(f010).</action>
+ <action type="fix" dev="ggregory" due-to="Gary
Gregory">Memoizer: default caches the first failure forever, has no size bound
or eviction, and runs the user computation inside the ConcurrentHashMap bin
lock (blocking unrelated keys, deadlocking reentrant use) (f011).</action>
+
<!-- ADD -->
<action type="add" dev="ggregory" due-to="Gary
Gregory">Add JavaVersion.JAVA_27.</action>
<action type="add" dev="ggregory" due-to="Gary
Gregory">Add SystemUtils.IS_JAVA_27.</action>
diff --git a/src/main/java/org/apache/commons/lang3/concurrent/Memoizer.java
b/src/main/java/org/apache/commons/lang3/concurrent/Memoizer.java
index 1862b62b4..c167311d5 100644
--- a/src/main/java/org/apache/commons/lang3/concurrent/Memoizer.java
+++ b/src/main/java/org/apache/commons/lang3/concurrent/Memoizer.java
@@ -21,6 +21,7 @@
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;
+import java.util.concurrent.FutureTask;
import java.util.function.Function;
import org.apache.commons.lang3.exception.ExceptionUtils;
@@ -30,10 +31,12 @@
* results for the calculation will be cached for future requests.
*
* <p>
- * This is not a fully functional cache, there is no way of limiting or
removing results once they have been generated.
- * However, it is possible to get the implementation to regenerate the result
for a given parameter, if an error was
- * thrown during the previous calculation, by setting the option during the
construction of the class. If this is not
- * set the class will return the cached exception.
+ * This is not a fully functional cache: it is unbounded, and there is no way
of limiting or removing results once they
+ * have been generated. In particular, note the exception-caching default:
unless the {@code recalculate} constructor
+ * option is set to {@code true}, the <em>first</em> exception thrown by a
calculation for a given parameter is cached
+ * and rethrown for every future call with that parameter for the lifetime of
this instance - a single transient
+ * failure permanently poisons that key. Set {@code recalculate} to {@code
true} to retry failed calculations on
+ * subsequent calls instead.
* </p>
* <p>
* Thanks go to Brian Goetz, Tim Peierls and the members of JCP JSR-166 Expert
Group for coming up with the
@@ -47,7 +50,7 @@
public class Memoizer<I, O> implements Computable<I, O> {
private final ConcurrentMap<I, Future<O>> cache = new
ConcurrentHashMap<>();
- private final Function<? super I, ? extends Future<O>> mappingFunction;
+ private final Function<? super I, FutureTask<O>> mappingFunction;
private final boolean recalculate;
/**
@@ -74,7 +77,7 @@ public Memoizer(final Computable<I, O> computable) {
*/
public Memoizer(final Computable<I, O> computable, final boolean
recalculate) {
this.recalculate = recalculate;
- this.mappingFunction = k -> FutureTasks.run(() ->
computable.compute(k));
+ this.mappingFunction = k -> new FutureTask<>(() ->
computable.compute(k));
}
/**
@@ -103,7 +106,7 @@ public Memoizer(final Function<I, O> function) {
*/
public Memoizer(final Function<I, O> function, final boolean recalculate)
{
this.recalculate = recalculate;
- this.mappingFunction = k -> FutureTasks.run(() -> function.apply(k));
+ this.mappingFunction = k -> new FutureTask<>(() -> function.apply(k));
}
/**
@@ -111,9 +114,16 @@ public Memoizer(final Function<I, O> function, final
boolean recalculate) {
*
* <p>
* This cache will also cache exceptions that occur during the computation
if the {@code recalculate} parameter in the
- * constructor was set to {@code false}, or not set. Otherwise, if an
exception happened on the previous calculation,
+ * constructor was set to {@code false}, or not set: the first exception
thrown for a given argument is rethrown for
+ * every future call with that argument. Otherwise, if an exception
happened on the previous calculation,
* the method will attempt again to generate a value.
* </p>
+ * <p>
+ * The calculation for a given argument runs at most once per cached entry
and executes <em>outside</em> any internal
+ * lock of the backing map (the pattern published in <em>Java Concurrency
in Practice</em>): a slow calculation for
+ * one key does not block calls for unrelated keys, and a calculation may
itself use this Memoizer without
+ * deadlocking. Concurrent callers for the same argument wait on the same
{@link Future}.
+ * </p>
*
* @param arg The argument for the calculation
* @return The result of the calculation
@@ -122,7 +132,18 @@ public Memoizer(final Function<I, O> function, final
boolean recalculate) {
@Override
public O compute(final I arg) throws InterruptedException {
while (true) {
- final Future<O> future = cache.computeIfAbsent(arg,
mappingFunction);
+ Future<O> future = cache.get(arg);
+ if (future == null) {
+ final FutureTask<O> futureTask = mappingFunction.apply(arg);
+ future = cache.putIfAbsent(arg, futureTask);
+ if (future == null) {
+ // This thread won the race to install the task: run the
user computation here,
+ // outside the ConcurrentHashMap's internal locks. Losing
threads (and later
+ // callers) block on futureTask.get() instead of on a map
bin lock.
+ future = futureTask;
+ futureTask.run();
+ }
+ }
try {
return future.get();
} catch (final CancellationException e) {
diff --git
a/src/test/java/org/apache/commons/lang3/concurrent/MemoizerCacheTest.java
b/src/test/java/org/apache/commons/lang3/concurrent/MemoizerCacheTest.java
new file mode 100644
index 000000000..767541887
--- /dev/null
+++ b/src/test/java/org/apache/commons/lang3/concurrent/MemoizerCacheTest.java
@@ -0,0 +1,227 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.lang3.concurrent;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+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.ArrayList;
+import java.util.List;
+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 java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Function;
+
+import org.apache.commons.lang3.AbstractLangTest;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+/**
+ * Tests failure retention, cache retention, and computation lock scope.
+ */
+class MemoizerCacheTest extends AbstractLangTest {
+
+ private static final int TIMEOUT_SECONDS = 10;
+
+ private static void await(final CountDownLatch latch) {
+ try {
+ assertTrue(latch.await(TIMEOUT_SECONDS, TimeUnit.SECONDS), "Timed
out waiting for a test worker");
+ } catch (final InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new AssertionError(e);
+ }
+ }
+
+ private static <I, O> Memoizer<I, O> newMemoizer(final boolean
useFunction, final Function<I, O> function) {
+ return useFunction ? new Memoizer<>(function) : new
Memoizer<>((Computable<I, O>) function::apply);
+ }
+
+ private static <I, O> Memoizer<I, O> newMemoizer(final boolean
useFunction, final Function<I, O> function, final boolean recalculate) {
+ return useFunction ? new Memoizer<>(function, recalculate) : new
Memoizer<>((Computable<I, O>) function::apply, recalculate);
+ }
+
+ private static void shutdown(final ExecutorService executor) throws
InterruptedException {
+ executor.shutdownNow();
+ assertTrue(executor.awaitTermination(TIMEOUT_SECONDS,
TimeUnit.SECONDS), "Test workers did not terminate");
+ }
+
+ @ParameterizedTest
+ @ValueSource(booleans = { false, true })
+ void testConcurrentCallsForSameKeyComputeOnce(final boolean useFunction)
throws Exception {
+ final int callerCount = 8;
+ final AtomicInteger calls = new AtomicInteger();
+ final CountDownLatch ready = new CountDownLatch(callerCount);
+ final CountDownLatch start = new CountDownLatch(1);
+ final CountDownLatch entered = new CountDownLatch(1);
+ final CountDownLatch release = new CountDownLatch(1);
+ final Object result = new Object();
+ final Memoizer<String, Object> memoizer = newMemoizer(useFunction, key
-> {
+ calls.incrementAndGet();
+ entered.countDown();
+ await(release);
+ return result;
+ });
+ final ExecutorService executor =
Executors.newFixedThreadPool(callerCount);
+ try {
+ final List<Future<Object>> futures = new ArrayList<>();
+ for (int i = 0; i < callerCount; i++) {
+ futures.add(executor.submit(() -> {
+ ready.countDown();
+ await(start);
+ return memoizer.compute("key");
+ }));
+ }
+ await(ready);
+ start.countDown();
+ await(entered);
+ release.countDown();
+ for (final Future<Object> future : futures) {
+ assertSame(result, future.get(TIMEOUT_SECONDS,
TimeUnit.SECONDS));
+ }
+ assertSame(result, memoizer.compute("key"));
+ assertEquals(1, calls.get());
+ } finally {
+ start.countDown();
+ release.countDown();
+ shutdown(executor);
+ }
+ }
+
+ @ParameterizedTest
+ @ValueSource(booleans = { false, true })
+ void testDefaultCachesFirstFailure(final boolean useFunction) throws
Exception {
+ final AtomicInteger calls = new AtomicInteger();
+ final IllegalStateException failure = new
IllegalStateException("Transient failure");
+ final Memoizer<String, String> memoizer = newMemoizer(useFunction, key
-> {
+ if (calls.incrementAndGet() == 1) {
+ throw failure;
+ }
+ return key;
+ });
+ for (int i = 0; i < 3; i++) {
+ assertSame(failure, assertThrows(IllegalStateException.class, ()
-> memoizer.compute("failed")));
+ }
+ assertEquals(1, calls.get(), "A transient failure remains cached by
default");
+ assertEquals("other", memoizer.compute("other"));
+ assertSame(failure, assertThrows(IllegalStateException.class, () ->
memoizer.compute("failed")));
+ assertEquals(2, calls.get());
+ }
+
+ @ParameterizedTest
+ @ValueSource(booleans = { false, true })
+ void testDistinctKeysRetainCachedResults(final boolean useFunction) throws
Exception {
+ final int keyCount = 1024;
+ final AtomicInteger calls = new AtomicInteger();
+ final Memoizer<Integer, Object> memoizer = newMemoizer(useFunction,
key -> {
+ calls.incrementAndGet();
+ return new Object();
+ });
+ final List<Object> results = new ArrayList<>();
+ for (int i = 0; i < keyCount; i++) {
+ results.add(memoizer.compute(i));
+ }
+ // Characterize retention over a bounded sample without exhausting
memory or inspecting the backing map.
+ for (int i = 0; i < keyCount; i++) {
+ assertSame(results.get(i), memoizer.compute(i));
+ }
+ assertEquals(keyCount, calls.get(), "Adding distinct keys must not
evict earlier results");
+ }
+
+ @ParameterizedTest
+ @ValueSource(booleans = { false, true })
+ void testRecalculateRetriesFailureOnNextCall(final boolean useFunction)
throws Exception {
+ final AtomicInteger calls = new AtomicInteger();
+ final IllegalStateException failure = new
IllegalStateException("Transient failure");
+ final Object result = new Object();
+ final Memoizer<String, Object> memoizer = newMemoizer(useFunction, key
-> {
+ if (calls.incrementAndGet() == 1) {
+ throw failure;
+ }
+ return result;
+ }, true);
+ assertSame(failure, assertThrows(IllegalStateException.class, () ->
memoizer.compute("key")));
+ assertEquals(1, calls.get(), "The failing call must propagate its
failure without retrying internally");
+ assertSame(result, memoizer.compute("key"));
+ assertSame(result, memoizer.compute("key"));
+ assertEquals(2, calls.get());
+ }
+
+ @ParameterizedTest
+ @ValueSource(booleans = { false, true })
+ void testReentrantComputationForDistinctCollidingKey(final boolean
useFunction) throws Exception {
+ assertEquals("Aa".hashCode(), "BB".hashCode());
+ final AtomicInteger calls = new AtomicInteger();
+ final AtomicReference<Memoizer<String, String>> reference = new
AtomicReference<>();
+ final Memoizer<String, String> memoizer = newMemoizer(useFunction, key
-> {
+ calls.incrementAndGet();
+ if ("Aa".equals(key)) {
+ try {
+ return reference.get().compute("BB");
+ } catch (final InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new AssertionError(e);
+ }
+ }
+ return key;
+ });
+ reference.set(memoizer);
+ final ExecutorService executor = Executors.newSingleThreadExecutor();
+ try {
+ assertEquals("BB", executor.submit(() ->
memoizer.compute("Aa")).get(TIMEOUT_SECONDS, TimeUnit.SECONDS));
+ assertEquals("BB", memoizer.compute("Aa"));
+ assertEquals("BB", memoizer.compute("BB"));
+ assertEquals(2, calls.get());
+ } finally {
+ shutdown(executor);
+ }
+ }
+
+ @ParameterizedTest
+ @ValueSource(booleans = { false, true })
+ void testSlowComputationDoesNotBlockDistinctCollidingKey(final boolean
useFunction) throws Exception {
+ assertEquals("Aa".hashCode(), "BB".hashCode());
+ final CountDownLatch entered = new CountDownLatch(1);
+ final CountDownLatch release = new CountDownLatch(1);
+ final Memoizer<String, String> memoizer = newMemoizer(useFunction, key
-> {
+ if ("Aa".equals(key)) {
+ entered.countDown();
+ await(release);
+ }
+ return key;
+ });
+ final ExecutorService executor = Executors.newFixedThreadPool(2);
+ try {
+ final Future<String> slow = executor.submit(() ->
memoizer.compute("Aa"));
+ await(entered);
+ final Future<String> other = executor.submit(() ->
memoizer.compute("BB"));
+ assertEquals("BB", other.get(TIMEOUT_SECONDS, TimeUnit.SECONDS));
+ assertEquals(1L, release.getCount(), "The colliding key must
complete while the first computation is blocked");
+ release.countDown();
+ assertEquals("Aa", slow.get(TIMEOUT_SECONDS, TimeUnit.SECONDS));
+ } finally {
+ release.countDown();
+ shutdown(executor);
+ }
+ }
+}