diff --git a/base-foundation/src/main/java/build/base/foundation/Introspection.java b/base-foundation/src/main/java/build/base/foundation/Introspection.java index 631aa9a..ace3955 100644 --- a/base-foundation/src/main/java/build/base/foundation/Introspection.java +++ b/base-foundation/src/main/java/build/base/foundation/Introspection.java @@ -20,6 +20,7 @@ * #L% */ +import build.base.foundation.memoizer.Memoizer; import build.base.foundation.stream.Streamable; import java.lang.annotation.Annotation; @@ -143,13 +144,17 @@ public final class Introspection { defaultValues.put(String.class, ""); DEFAULT_VALUES = java.util.Collections.unmodifiableMap(defaultValues); - ALL_DECLARED_FIELDS_BY_CLASS = new Memoizer<>(clazz -> extractAll(clazz, Class::getDeclaredFields)); + ALL_DECLARED_FIELDS_BY_CLASS = Memoizer., Streamable>of( + clazz -> extractAll(clazz, Class::getDeclaredFields)).concurrent().build(); - ALL_DECLARED_METHODS_BY_CLASS = new Memoizer<>(clazz -> extractAll(clazz, Class::getDeclaredMethods)); + ALL_DECLARED_METHODS_BY_CLASS = Memoizer., Streamable>of( + clazz -> extractAll(clazz, Class::getDeclaredMethods)).concurrent().build(); - ALL_DECLARED_ANNOTATIONS_BY_CLASS = new Memoizer<>(clazz -> - new Memoizer<>(annotationClass -> - extractAll(clazz, c -> c.getDeclaredAnnotationsByType(annotationClass)))); + ALL_DECLARED_ANNOTATIONS_BY_CLASS = Memoizer., Memoizer, Streamable>>of( + clazz -> Memoizer., Streamable>of( + annotationClass -> extractAll(clazz, c -> c.getDeclaredAnnotationsByType(annotationClass)) + ).concurrent().build() + ).concurrent().build(); } /** diff --git a/base-foundation/src/main/java/build/base/foundation/Memoizer.java b/base-foundation/src/main/java/build/base/foundation/Memoizer.java deleted file mode 100644 index 535481b..0000000 --- a/base-foundation/src/main/java/build/base/foundation/Memoizer.java +++ /dev/null @@ -1,158 +0,0 @@ -package build.base.foundation; - -/*- - * #%L - * base.build Foundation - * %% - * Copyright (C) 2025 Workday Inc - * %% - * Licensed 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 - * - * http://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. - * #L% - */ - -import java.util.Objects; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.locks.ReentrantLock; -import java.util.function.Function; - -/** - * A thread-safe Memoizer that memoizes the results - * of {@link Function} invocations to avoid repetitive invocations for the same input parameters, including support for - * {@code null} inputs and results. - *

- * This implementation provides a simple way to memoize the results of expensive {@link Function} calls using a - * {@link ConcurrentWeakHashMap}. Once a result is computed for {@link Function} invocation with a specific - * input, subsequent invocations with the same input will return the memoized result without re-invocation of the - * {@link Function}, assuming the result has not been garbage collected, in which case, the result will be recomputed - * through re-invocation. - * - * @param the type of the input parameter - * @param the type of the result - * @author reed.vonredwitz - * @since Jul-2025 - */ -public class Memoizer { - - /** - * The {@link ConcurrentWeakHashMap} used to cache results by input - */ - private final ConcurrentWeakHashMap cache; - - /** - * {@link ReentrantLock}s by key to ensure thread-safe re-entrant computation. - */ - private final ConcurrentHashMap computeLocks; - - /** - * The {@link Function} for which invocations will be memoized. - */ - private final Function function; - - /** - * The {@link Object} used as the key for {@code null} inputs and invocation results. - */ - private static final Object NULL_KEY = new Object(); - - /** - * Constructs a new memoizer with the specified {@link Function}. - * - * @param function the {@link Function} to memoize; must not be {@code null} - * @throws IllegalArgumentException should the {@link Function} be {@code null} - */ - public Memoizer(final Function function) { - Objects.requireNonNull(function, "The memoizing Function must not be null"); - this.cache = new ConcurrentWeakHashMap<>(); - this.computeLocks = new ConcurrentHashMap<>(); - this.function = function; - } - - /** - * Computes the result for the given input, using the memoized result if available. - *

- * If the result for the given input has already been computed and memoized, this method returns - * the cached result immediately. Otherwise, it computes the result by invoking the {@link Function}, - * caches it, and returns the computed result. - *

- *

- * This method is thread-safe and can be called concurrently from multiple threads. - *

- * - * @param input the input parameter; may be {@code null} - * @return the computed or cached result - */ - public R compute(final T input) { - final var key = input == null ? NULL_KEY : input; - - var result = this.cache.get(key); - - if (result != null) { - return result; - } - - // obtain a lock for computing the value for the key - final var lock = this.computeLocks.computeIfAbsent(key, k -> new ReentrantLock()); - - // the following algorithm allows for re-entrant computation of keys with the same hash code or - // those causing collisions in the hash when attempting to concurrently update the cache. - // (while it may seem like replace this code with cache.computeIfAbsent would be a good idea, that method - // may experience hash collisions and compute the value multiple times) - try { - lock.lock(); - - result = this.cache.get(key); - - if (result != null) { - return result; - } - - result = this.function.apply(input); - - this.cache.put(key, result); - - return result; - } finally { - lock.unlock(); - this.computeLocks.remove(key); - } - } - - /** - * Clears all memoized results, forcing subsequent calls to re-compute results. - *

- * This method is thread-safe and can be called concurrently with {@link #compute(Object)}. - *

- */ - public void clear() { - this.cache.clear(); - } - - /** - * Obtains the approximate number of memoized results. - * - * @return the approximate number of memoized results - */ - public int size() { - return this.cache.size(); - } - - /** - * Determines if the {@link Memoizer} contains a result for the given input. - * - * @param input the input to check - * @return {@code true} if the cache contains a result for the input, {@code false} otherwise - */ - public boolean contains(final T input) { - final var key = input == null ? NULL_KEY : input; - return this.cache.containsKey(key); - } -} diff --git a/base-foundation/src/main/java/build/base/foundation/memoizer/AbstractConcurrentMemoizer.java b/base-foundation/src/main/java/build/base/foundation/memoizer/AbstractConcurrentMemoizer.java new file mode 100644 index 0000000..bfbd457 --- /dev/null +++ b/base-foundation/src/main/java/build/base/foundation/memoizer/AbstractConcurrentMemoizer.java @@ -0,0 +1,107 @@ +package build.base.foundation.memoizer; + +/*- + * #%L + * base.build Foundation + * %% + * Copyright (C) 2026 Workday Inc + * %% + * Licensed 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 + * + * http://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. + * #L% + */ + +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.locks.ReentrantLock; +import java.util.function.Function; +import java.util.function.Supplier; + +/** + * Abstract base for thread-safe {@link Memoizer} implementations. + *

+ * Uses a per-key {@link ReentrantLock} to synchronize cache misses. Locks are allocated on first + * miss and removed after the result is stored, keeping the lock map lean. Cache hits follow a + * lock-free fast path. + *

+ * {@link ReentrantLock} is used rather than {@code ConcurrentHashMap.computeIfAbsent} because the + * memoized function may re-enter this memoizer for a key that hashes to the same map bin. + * {@code computeIfAbsent} holds the bin lock during computation, so a re-entrant call on the same + * bin would deadlock or throw {@link IllegalStateException}. The per-key lock approach holds no bin + * lock during computation, so re-entrant calls always succeed. + *

+ * The supplied cache {@link Map} must implement {@link ConcurrentMap} to ensure the fast-path read + * is visible across threads without holding a lock. + *

+ * Subclasses may customize cache access by overriding {@link #readCache(Object)} and + * {@link #writeCache(Object, Object)} — for example, to wrap results in + * {@link java.lang.ref.SoftReference}s as {@link SoftConcurrentMemoizer} does. + * + * @param the input type + * @param the result type + * @author reed.vonredwitz + * @since Jun-2026 + */ +public abstract class AbstractConcurrentMemoizer extends BaseMemoizer { + + private final ConcurrentMap computeLocks; + + /** + * Constructs an {@link AbstractConcurrentMemoizer}. + * + * @param function the function to memoize; must not be {@code null} + * @param mapSupplier supplies the backing cache {@link ConcurrentMap}; must not be {@code null} + * @param locksSupplier supplies the per-key lock {@link ConcurrentMap}; must not be {@code null} + * @throws IllegalArgumentException if the map returned by {@code mapSupplier} does not implement + * {@link ConcurrentMap} + */ + protected AbstractConcurrentMemoizer( + final Function function, + final Supplier> mapSupplier, + final Supplier> locksSupplier) { + super(function, mapSupplier); + if (!(this.cache instanceof ConcurrentMap)) { + throw new IllegalArgumentException( + "The supplied Map must implement ConcurrentMap to support lock-free reads"); + } + this.computeLocks = Objects.requireNonNull( + Objects.requireNonNull(locksSupplier, "locksSupplier must not be null").get(), + "the supplied ConcurrentMap must not be null"); + } + + @Override + public final R compute(final T input) { + final var key = input == null ? NULL_KEY : input; + + // fast path — no lock needed on a cache hit + var cached = readCache(key); + if (cached != null) { + return unwrap(cached); + } + + final var lock = this.computeLocks.computeIfAbsent(key, k -> new ReentrantLock()); + lock.lock(); + try { + cached = readCache(key); + if (cached != null) { + return unwrap(cached); + } + final R result = this.function.apply(input); + writeCache(key, wrap(result)); + return result; + } finally { + lock.unlock(); + this.computeLocks.remove(key); + } + } +} diff --git a/base-foundation/src/main/java/build/base/foundation/memoizer/AbstractMemoizer.java b/base-foundation/src/main/java/build/base/foundation/memoizer/AbstractMemoizer.java new file mode 100644 index 0000000..92c698c --- /dev/null +++ b/base-foundation/src/main/java/build/base/foundation/memoizer/AbstractMemoizer.java @@ -0,0 +1,58 @@ +package build.base.foundation.memoizer; + +/*- + * #%L + * base.build Foundation + * %% + * Copyright (C) 2026 Workday Inc + * %% + * Licensed 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 + * + * http://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. + * #L% + */ + +import java.util.Map; +import java.util.function.Function; +import java.util.function.Supplier; + +/** + * Abstract base for non-thread-safe {@link Memoizer} implementations. + *

+ * Subclasses may customize cache access by overriding {@link #readCache(Object)} and + * {@link #writeCache(Object, Object)} — for example, to wrap results in + * {@link java.lang.ref.SoftReference}s as {@link SoftMemoizer} does. + *

+ * This class is not thread-safe. For concurrent use see {@link AbstractConcurrentMemoizer}. + * + * @param the input type + * @param the result type + * @author reed.vonredwitz + * @since Jun-2026 + */ +public abstract class AbstractMemoizer extends BaseMemoizer { + + protected AbstractMemoizer(final Function function, final Supplier> mapSupplier) { + super(function, mapSupplier); + } + + @Override + public final R compute(final T input) { + final var key = input == null ? NULL_KEY : input; + final var cached = readCache(key); + if (cached != null) { + return unwrap(cached); + } + final R result = this.function.apply(input); + writeCache(key, wrap(result)); + return result; + } +} diff --git a/base-foundation/src/main/java/build/base/foundation/memoizer/BaseMemoizer.java b/base-foundation/src/main/java/build/base/foundation/memoizer/BaseMemoizer.java new file mode 100644 index 0000000..9c1e16d --- /dev/null +++ b/base-foundation/src/main/java/build/base/foundation/memoizer/BaseMemoizer.java @@ -0,0 +1,110 @@ +package build.base.foundation.memoizer; + +/*- + * #%L + * base.build Foundation + * %% + * Copyright (C) 2026 Workday Inc + * %% + * Licensed 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 + * + * http://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. + * #L% + */ + +import java.util.Map; +import java.util.Objects; +import java.util.function.Function; +import java.util.function.Supplier; + +/** + * Package-private base for {@link AbstractMemoizer} and {@link AbstractConcurrentMemoizer}, providing + * shared infrastructure: null sentinels, the backing cache, the memoized function, and default + * {@link #readCache}/{@link #writeCache} hooks. + * + * @param the input type + * @param the result type + */ +abstract class BaseMemoizer implements Memoizer { + + /** + * Sentinel used as the map key for {@code null} inputs. + */ + protected static final Object NULL_KEY = new Object(); + + /** + * Sentinel stored in the cache as the map value for {@code null} results, since the backing + * maps do not permit {@code null} values. + */ + protected static final Object NULL_RESULT = new Object(); + + /** + * The backing cache. Values are either direct results (strong implementations) or + * {@link java.lang.ref.SoftReference} wrappers (soft implementations). + */ + protected final Map cache; + + /** + * The function whose results are memoized. + */ + protected final Function function; + + protected BaseMemoizer(final Function function, final Supplier> mapSupplier) { + this.function = Objects.requireNonNull(function, "function must not be null"); + Objects.requireNonNull(mapSupplier, "mapSupplier must not be null"); + this.cache = Objects.requireNonNull(mapSupplier.get(), "the supplied Map must not be null"); + } + + /** + * Reads a value from the cache for the given key. Returns {@code null} if absent (or if a + * soft reference has been collected). Subclasses override this to unwrap soft references. + * + * @param key the cache key (never {@code null}; callers substitute {@link #NULL_KEY}) + * @return the cached value, or {@code null} if not present + */ + protected Object readCache(final Object key) { + return this.cache.get(key); + } + + /** + * Writes a value to the cache. Subclasses override this to wrap results in soft references. + * + * @param key the cache key + * @param value the value to cache (never {@code null}; {@link #NULL_RESULT} is used for null results) + */ + protected void writeCache(final Object key, final Object value) { + this.cache.put(key, value); + } + + @Override + public void clear() { + this.cache.clear(); + } + + @Override + public int size() { + return this.cache.size(); + } + + @Override + public boolean contains(final T input) { + return readCache(input == null ? NULL_KEY : input) != null; + } + + @SuppressWarnings("unchecked") + protected static R unwrap(final Object value) { + return value == NULL_RESULT ? null : (R) value; + } + + protected static Object wrap(final Object value) { + return value == null ? NULL_RESULT : value; + } +} diff --git a/base-foundation/src/main/java/build/base/foundation/memoizer/Memoizer.java b/base-foundation/src/main/java/build/base/foundation/memoizer/Memoizer.java new file mode 100644 index 0000000..d41afeb --- /dev/null +++ b/base-foundation/src/main/java/build/base/foundation/memoizer/Memoizer.java @@ -0,0 +1,193 @@ +package build.base.foundation.memoizer; + +/*- + * #%L + * base.build Foundation + * %% + * Copyright (C) 2026 Workday Inc + * %% + * Licensed 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 + * + * http://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. + * #L% + */ + +import build.base.foundation.ConcurrentWeakHashMap; + +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.WeakHashMap; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.locks.ReentrantLock; +import java.util.function.Function; +import java.util.function.Supplier; + +/** + * A thread-safety-agnostic contract for memoization + * of {@link Function} invocations. + *

+ * Instances are obtained via the builder: + *

{@code
+ * Memoizer.of(fn).build()                              // non-concurrent, strong references
+ * Memoizer.of(fn).soft().build()                       // non-concurrent, soft references (GC-evictable values)
+ * Memoizer.of(fn).weak().build()                       // non-concurrent, weak keys (entries evicted when key is unreachable)
+ * Memoizer.of(fn).concurrent().build()                 // concurrent, strong references
+ * Memoizer.of(fn).soft().concurrent().build()          // concurrent, soft references
+ * Memoizer.of(fn).weak().concurrent().build()          // concurrent, weak keys
+ * Memoizer.of(fn).concurrent().withLocks(supplier)...  // concurrent with custom lock map
+ * }
+ * Custom implementations can extend {@link AbstractMemoizer} or {@link AbstractConcurrentMemoizer}. + * + * @param the input type + * @param the result type + * @author reed.vonredwitz + * @since Jun-2026 + */ +public interface Memoizer { + + /** + * Returns the memoized result for {@code input}, computing it via the underlying {@link Function} + * on the first call for a given input. + * + * @param input the input; may be {@code null} + * @return the memoized result; may be {@code null} + */ + R compute(T input); + + /** + * Clears all memoized results, forcing subsequent calls to recompute. + */ + void clear(); + + /** + * Returns the number of memoized results. For soft-reference and weak-key implementations + * this count is approximate, as entries may have been collected by the GC. + * + * @return the number of memoized results + */ + int size(); + + /** + * Returns {@code true} if a result is currently memoized for {@code input}. + * + * @param input the input to check; may be {@code null} + * @return {@code true} if a memoized result is present + */ + boolean contains(T input); + + /** + * Returns a {@link Builder} for the given function. + * + * @param function the function to memoize; must not be {@code null} + */ + static Builder of(final Function function) { + return new Builder<>(function); + } + + /** + * Builder for non-concurrent {@link Memoizer} instances. Defaults to strong references; + * call {@link #soft()} or {@link #weak()} to change the eviction strategy. Call + * {@link #concurrent()} to obtain a {@link ConcurrentBuilder} that adds thread-safety options. + * + * @param the input type + * @param the result type + */ + final class Builder { + + final Function function; + Refs refs = Refs.STRONG; + Supplier> mapSupplier; + + Builder(final Function function) { + this.function = Objects.requireNonNull(function, "function must not be null"); + } + + /** Uses soft references; entries may be evicted under memory pressure. */ + public Builder soft() { + this.refs = Refs.SOFT; + return this; + } + + /** + * Uses weak keys; an entry is evicted once its key is no longer strongly reachable. + * Suitable when the memoizer's lifetime should be governed by the key's lifetime rather + * than by memory pressure. + */ + public Builder weak() { + this.refs = Refs.WEAK; + return this; + } + + /** + * Overrides the default backing cache map. + */ + public Builder withMap(final Supplier> mapSupplier) { + this.mapSupplier = Objects.requireNonNull(mapSupplier, "mapSupplier must not be null"); + return this; + } + + /** + * Returns a {@link ConcurrentBuilder} that will produce a thread-safe memoizer using a + * per-key lock. Carries over any {@link #soft()}, {@link #weak()}, and {@link #withMap} + * configuration. + */ + public ConcurrentBuilder concurrent() { + return new ConcurrentBuilder<>(this); + } + + /** Builds the configured non-concurrent {@link Memoizer}. */ + public Memoizer build() { + return switch (refs) { + case SOFT -> new SoftMemoizer<>(function, mapSupplier != null ? mapSupplier : HashMap::new); + case WEAK -> new WeakMemoizer<>(function, mapSupplier != null ? mapSupplier : WeakHashMap::new); + default -> new StrongMemoizer<>(function, mapSupplier != null ? mapSupplier : HashMap::new); + }; + } + } + + /** + * Builder for concurrent {@link Memoizer} instances, obtained by calling + * {@link Builder#concurrent()}. Reference mode and backing map are inherited from the + * {@link Builder}; call {@link #withLocks(Supplier)} to supply a custom per-key lock map. + * + * @param the input type + * @param the result type + */ + final class ConcurrentBuilder { + + private final Builder base; + private Supplier> locksSupplier; + + ConcurrentBuilder(final Builder base) { + this.base = base; + } + + /** + * Overrides the default per-key lock map. + */ + public ConcurrentBuilder withLocks(final Supplier> locksSupplier) { + this.locksSupplier = Objects.requireNonNull(locksSupplier, "locksSupplier must not be null"); + return this; + } + + /** Builds the configured concurrent {@link Memoizer}. */ + public Memoizer build() { + final Supplier> ls = locksSupplier != null ? locksSupplier : ConcurrentHashMap::new; + return switch (base.refs) { + case SOFT -> new SoftConcurrentMemoizer<>(base.function, base.mapSupplier != null ? base.mapSupplier : ConcurrentHashMap::new, ls); + case WEAK -> new WeakConcurrentMemoizer<>(base.function, base.mapSupplier != null ? base.mapSupplier : ConcurrentWeakHashMap::new, ls); + default -> new StrongConcurrentMemoizer<>(base.function, base.mapSupplier != null ? base.mapSupplier : ConcurrentHashMap::new, ls); + }; + } + } +} diff --git a/base-foundation/src/main/java/build/base/foundation/memoizer/Refs.java b/base-foundation/src/main/java/build/base/foundation/memoizer/Refs.java new file mode 100644 index 0000000..e1503ac --- /dev/null +++ b/base-foundation/src/main/java/build/base/foundation/memoizer/Refs.java @@ -0,0 +1,26 @@ +package build.base.foundation.memoizer; + +/*- + * #%L + * base.build Foundation + * %% + * Copyright (C) 2026 Workday, Inc. + * %% + * Licensed 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 + * + * http://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. + * #L% + */ + +/** + * Reference mode used by {@link Memoizer.Builder} and {@link Memoizer.ConcurrentBuilder}. + */ +enum Refs {STRONG, SOFT, WEAK} diff --git a/base-foundation/src/main/java/build/base/foundation/memoizer/SoftConcurrentMemoizer.java b/base-foundation/src/main/java/build/base/foundation/memoizer/SoftConcurrentMemoizer.java new file mode 100644 index 0000000..f00ca9b --- /dev/null +++ b/base-foundation/src/main/java/build/base/foundation/memoizer/SoftConcurrentMemoizer.java @@ -0,0 +1,74 @@ +package build.base.foundation.memoizer; + +/*- + * #%L + * base.build Foundation + * %% + * Copyright (C) 2026 Workday Inc + * %% + * Licensed 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 + * + * http://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. + * #L% + */ + +import java.lang.ref.SoftReference; +import java.util.Map; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.locks.ReentrantLock; +import java.util.function.Function; +import java.util.function.Supplier; + +/** + * A thread-safe {@link Memoizer} that holds memoized results with {@link SoftReference}s, + * allowing the JVM to reclaim them under memory pressure. Evicted entries are transparently + * recomputed on the next {@link #compute(Object)} call. + *

+ * The fast-path read ({@link #readCache}) does not evict stale references to avoid a race between + * concurrent reads and writes. Stale wrappers are overwritten when the value is recomputed under + * the per-key lock. + *

+ * {@link #size()} is approximate: it counts all entries including those whose referents have + * already been collected. + *

+ * For a non-concurrent variant see {@link SoftMemoizer}. + * + * @param the input type + * @param the result type + * @author reed.vonredwitz + * @since Jun-2026 + */ +final class SoftConcurrentMemoizer extends AbstractConcurrentMemoizer { + + SoftConcurrentMemoizer( + final Function function, + final Supplier> mapSupplier, + final Supplier> locksSupplier) { + super(function, mapSupplier, locksSupplier); + } + + @Override + @SuppressWarnings("unchecked") + protected Object readCache(final Object key) { + final var ref = (SoftReference) this.cache.get(key); + if (ref == null) { + return null; + } + // Returns null if the referent was collected; caller falls through to locked recomputation. + // We intentionally do not evict the stale wrapper here to avoid a race with a concurrent write. + return ref.get(); + } + + @Override + protected void writeCache(final Object key, final Object value) { + this.cache.put(key, new SoftReference<>(value)); + } +} diff --git a/base-foundation/src/main/java/build/base/foundation/memoizer/SoftMemoizer.java b/base-foundation/src/main/java/build/base/foundation/memoizer/SoftMemoizer.java new file mode 100644 index 0000000..582b296 --- /dev/null +++ b/base-foundation/src/main/java/build/base/foundation/memoizer/SoftMemoizer.java @@ -0,0 +1,68 @@ +package build.base.foundation.memoizer; + +/*- + * #%L + * base.build Foundation + * %% + * Copyright (C) 2026 Workday Inc + * %% + * Licensed 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 + * + * http://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. + * #L% + */ + +import java.lang.ref.SoftReference; +import java.util.Map; +import java.util.function.Function; +import java.util.function.Supplier; + +/** + * A non-thread-safe {@link Memoizer} that holds memoized results with {@link SoftReference}s, + * allowing the JVM to reclaim them under memory pressure. Evicted entries are transparently + * recomputed on the next {@link #compute(Object)} call. + *

+ * {@link #size()} is approximate: it counts all entries including those whose referents have + * already been collected. + *

+ * For concurrent use see {@link SoftConcurrentMemoizer}. + * + * @param the input type + * @param the result type + * @author reed.vonredwitz + * @since Jun-2026 + */ +final class SoftMemoizer extends AbstractMemoizer { + + SoftMemoizer(final Function function, final Supplier> mapSupplier) { + super(function, mapSupplier); + } + + @Override + @SuppressWarnings("unchecked") + protected Object readCache(final Object key) { + final var ref = (SoftReference) this.cache.get(key); + if (ref == null) { + return null; + } + final var value = ref.get(); + if (value == null) { + this.cache.remove(key); + return null; + } + return value; + } + + @Override + protected void writeCache(final Object key, final Object value) { + this.cache.put(key, new SoftReference<>(value)); + } +} diff --git a/base-foundation/src/main/java/build/base/foundation/memoizer/StrongConcurrentMemoizer.java b/base-foundation/src/main/java/build/base/foundation/memoizer/StrongConcurrentMemoizer.java new file mode 100644 index 0000000..7908156 --- /dev/null +++ b/base-foundation/src/main/java/build/base/foundation/memoizer/StrongConcurrentMemoizer.java @@ -0,0 +1,48 @@ +package build.base.foundation.memoizer; + +/*- + * #%L + * base.build Foundation + * %% + * Copyright (C) 2026 Workday Inc + * %% + * Licensed 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 + * + * http://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. + * #L% + */ + +import java.util.Map; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.locks.ReentrantLock; +import java.util.function.Function; +import java.util.function.Supplier; + +/** + * A thread-safe {@link Memoizer} that holds memoized results with strong references. + * Results are retained until {@link #clear()} is called or this instance is garbage collected. + *

+ * For a non-concurrent variant see {@link StrongMemoizer}. + * + * @param the input type + * @param the result type + * @author reed.vonredwitz + * @since Jun-2026 + */ +final class StrongConcurrentMemoizer extends AbstractConcurrentMemoizer { + + StrongConcurrentMemoizer( + final Function function, + final Supplier> mapSupplier, + final Supplier> locksSupplier) { + super(function, mapSupplier, locksSupplier); + } +} diff --git a/base-foundation/src/main/java/build/base/foundation/memoizer/StrongMemoizer.java b/base-foundation/src/main/java/build/base/foundation/memoizer/StrongMemoizer.java new file mode 100644 index 0000000..4eb94d8 --- /dev/null +++ b/base-foundation/src/main/java/build/base/foundation/memoizer/StrongMemoizer.java @@ -0,0 +1,43 @@ +package build.base.foundation.memoizer; + +/*- + * #%L + * base.build Foundation + * %% + * Copyright (C) 2026 Workday Inc + * %% + * Licensed 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 + * + * http://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. + * #L% + */ + +import java.util.Map; +import java.util.function.Function; +import java.util.function.Supplier; + +/** + * A non-thread-safe {@link Memoizer} that holds memoized results with strong references. + * Results are retained until {@link #clear()} is called or this instance is garbage collected. + *

+ * For concurrent use see {@link StrongConcurrentMemoizer}. + * + * @param the input type + * @param the result type + * @author reed.vonredwitz + * @since Jun-2026 + */ +final class StrongMemoizer extends AbstractMemoizer { + + StrongMemoizer(final Function function, final Supplier> mapSupplier) { + super(function, mapSupplier); + } +} diff --git a/base-foundation/src/main/java/build/base/foundation/memoizer/WeakConcurrentMemoizer.java b/base-foundation/src/main/java/build/base/foundation/memoizer/WeakConcurrentMemoizer.java new file mode 100644 index 0000000..8ab20b3 --- /dev/null +++ b/base-foundation/src/main/java/build/base/foundation/memoizer/WeakConcurrentMemoizer.java @@ -0,0 +1,52 @@ +package build.base.foundation.memoizer; + +/*- + * #%L + * base.build Foundation + * %% + * Copyright (C) 2026 Workday Inc + * %% + * Licensed 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 + * + * http://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. + * #L% + */ + +import java.util.Map; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.locks.ReentrantLock; +import java.util.function.Function; +import java.util.function.Supplier; + +/** + * A thread-safe {@link Memoizer} that holds keys with weak references, allowing the JVM to evict + * entries once a key is no longer strongly reachable. The entry for a key is recomputed + * transparently on the next {@link #compute(Object)} call after eviction. + *

+ * Use this when the memoizer's lifetime should follow the key's lifetime rather than memory + * pressure. For eviction driven by memory pressure see {@link SoftConcurrentMemoizer}. + *

+ * For non-concurrent use see {@link WeakMemoizer}. + * + * @param the input type + * @param the result type + * @author reed.vonredwitz + * @since Jun-2026 + */ +final class WeakConcurrentMemoizer extends AbstractConcurrentMemoizer { + + WeakConcurrentMemoizer( + final Function function, + final Supplier> mapSupplier, + final Supplier> locksSupplier) { + super(function, mapSupplier, locksSupplier); + } +} diff --git a/base-foundation/src/main/java/build/base/foundation/memoizer/WeakMemoizer.java b/base-foundation/src/main/java/build/base/foundation/memoizer/WeakMemoizer.java new file mode 100644 index 0000000..6b5fae1 --- /dev/null +++ b/base-foundation/src/main/java/build/base/foundation/memoizer/WeakMemoizer.java @@ -0,0 +1,47 @@ +package build.base.foundation.memoizer; + +/*- + * #%L + * base.build Foundation + * %% + * Copyright (C) 2026 Workday Inc + * %% + * Licensed 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 + * + * http://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. + * #L% + */ + +import java.util.Map; +import java.util.function.Function; +import java.util.function.Supplier; + +/** + * A non-thread-safe {@link Memoizer} that holds keys with weak references, allowing the JVM to + * evict entries once a key is no longer strongly reachable. The entry for a key is recomputed + * transparently on the next {@link #compute(Object)} call after eviction. + *

+ * Use this when the memoizer's lifetime should follow the key's lifetime rather than memory + * pressure. For eviction driven by memory pressure see {@link SoftMemoizer}. + *

+ * For concurrent use see {@link WeakConcurrentMemoizer}. + * + * @param the input type + * @param the result type + * @author reed.vonredwitz + * @since Jun-2026 + */ +final class WeakMemoizer extends AbstractMemoizer { + + WeakMemoizer(final Function function, final Supplier> mapSupplier) { + super(function, mapSupplier); + } +} diff --git a/base-foundation/src/main/java/module-info.java b/base-foundation/src/main/java/module-info.java index 64e1d17..33b6346 100644 --- a/base-foundation/src/main/java/module-info.java +++ b/base-foundation/src/main/java/module-info.java @@ -26,6 +26,7 @@ module build.base.foundation { exports build.base.foundation; exports build.base.foundation.iterator; + exports build.base.foundation.memoizer; exports build.base.foundation.iterator.matching; exports build.base.foundation.predicate; exports build.base.foundation.stream; diff --git a/base-foundation/src/test/java/build/base/foundation/MemoizerTests.java b/base-foundation/src/test/java/build/base/foundation/MemoizerTests.java deleted file mode 100644 index 91a31ff..0000000 --- a/base-foundation/src/test/java/build/base/foundation/MemoizerTests.java +++ /dev/null @@ -1,294 +0,0 @@ -package build.base.foundation; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -import java.util.Objects; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.function.Function; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -/** - * Tests for {@link Memoizer}. - * - * @author reed.vonredwitz - * @since Dec-2024 - */ -class MemoizerTests { - - private AtomicInteger callCount; - private Memoizer memoizer; - - @BeforeEach - void setUp() { - this.callCount = new AtomicInteger(0); - - // simulate an expensive computation - final Function expensiveFunction = input -> { - this.callCount.incrementAndGet(); - // Simulate expensive computation - try { - Thread.sleep(10); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - return input.length(); - }; - - this.memoizer = new Memoizer<>(expensiveFunction); - } - - /** - * Ensure memoizer returns correct result for first computation. - */ - @Test - void shouldComputeResultForFirstCall() { - final var result = this.memoizer.compute("hello"); - - assertThat(result).isEqualTo(5); - assertThat(this.callCount.get()).isEqualTo(1); - } - - /** - * Ensure memoizer returns cached result for subsequent calls. - */ - @Test - void shouldReturnCachedResultForSubsequentCalls() { - // First call - final var result1 = this.memoizer.compute("hello"); - assertThat(result1).isEqualTo(5); - assertThat(this.callCount.get()).isEqualTo(1); - - // Second call with same input - final var result2 = this.memoizer.compute("hello"); - assertThat(result2).isEqualTo(5); - assertThat(this.callCount.get()).isEqualTo(1); // Should not increment - } - - /** - * Ensure memoizer computes different results for different inputs. - */ - @Test - void shouldComputeDifferentResultsForDifferentInputs() { - final var result1 = this.memoizer.compute("hello"); - final var result2 = this.memoizer.compute("world"); - - assertThat(result1).isEqualTo(5); - assertThat(result2).isEqualTo(5); - assertThat(this.callCount.get()).isEqualTo(2); - } - - /** - * Ensure memoizer handles null input correctly. - */ - @Test - void shouldHandleNullInput() { - final var nullFunction = new Function() { - @Override - public Integer apply(String input) { - callCount.incrementAndGet(); - return input == null ? 0 : input.length(); - } - }; - final var nullMemoizer = new Memoizer<>(nullFunction); - - final var result = nullMemoizer.compute(null); - assertThat(result).isEqualTo(0); - assertThat(this.callCount.get()).isEqualTo(1); - } - - /** - * Ensure memoizer constructor throws exception for null function. - */ - @Test - void shouldThrowExceptionForNullFunction() { - assertThatThrownBy(() -> new Memoizer<>(null)) - .isInstanceOf(NullPointerException.class); - } - - /** - * Ensure clear method removes all cached results. - */ - @Test - void shouldClearAllCachedResults() { - // First call - this.memoizer.compute("hello"); - assertThat(this.callCount.get()).isEqualTo(1); - assertThat(this.memoizer.size()).isEqualTo(1); - - // Clear cache - this.memoizer.clear(); - assertThat(this.memoizer.size()).isEqualTo(0); - - // Second call should recompute - this.memoizer.compute("hello"); - assertThat(this.callCount.get()).isEqualTo(2); - assertThat(memoizer.size()).isEqualTo(1); - } - - /** - * Ensure size method returns correct cache size. - */ - @Test - void shouldReturnCorrectCacheSize() { - assertThat(this.memoizer.size()).isEqualTo(0); - - this.memoizer.compute("hello"); - assertThat(this.memoizer.size()).isEqualTo(1); - - this.memoizer.compute("world"); - assertThat(this.memoizer.size()).isEqualTo(2); - - this.memoizer.compute("hello"); // Should not increase size - assertThat(this.memoizer.size()).isEqualTo(2); - } - - /** - * Ensure contains method works correctly. - */ - @Test - void shouldCheckIfInputIsCached() { - assertThat(this.memoizer.contains("hello")).isFalse(); - - this.memoizer.compute("hello"); - assertThat(this.memoizer.contains("hello")).isTrue(); - assertThat(this.memoizer.contains("world")).isFalse(); - } - - /** - * Ensure memoizer is thread-safe. - */ - @Test - void shouldBeThreadSafe() throws InterruptedException { - final var threadCount = 10; - final var iterations = 100; - final var threads = new Thread[threadCount]; - - // Create threads that will call the memoizer concurrently - for (int i = 0; i < threadCount; i++) { - threads[i] = new Thread(() -> { - for (int j = 0; j < iterations; j++) { - this.memoizer.compute("concurrent"); - } - }); - } - - // Start all threads - for (final var thread : threads) { - thread.start(); - } - - // Wait for all threads to complete - for (final var thread : threads) { - thread.join(); - } - - // Verify that the function was only called once despite multiple threads - assertThat(this.callCount.get()).isEqualTo(1); - assertThat(this.memoizer.size()).isEqualTo(1); - assertThat(this.memoizer.contains("concurrent")).isTrue(); - } - - /** - * Ensure memoizer works with complex objects. - */ - @Test - void shouldWorkWithComplexObjects() { - final var complexFunction = new Function() { - @Override - public String apply(TestObject input) { - callCount.incrementAndGet(); - return input.name + "_" + input.value; - } - }; - final var complexMemoizer = new Memoizer<>(complexFunction); - - final var obj1 = new TestObject("test", 42); - final var obj2 = new TestObject("test", 42); // Same values, different instance - - final var result1 = complexMemoizer.compute(obj1); - final var result2 = complexMemoizer.compute(obj2); - - assertThat(result1).isEqualTo("test_42"); - assertThat(result2).isEqualTo("test_42"); - // Since obj1 and obj2 have the same equals/hashCode, they will be treated as the same key - assertThat(this.callCount.get()).isEqualTo(1); - } - - /** - * Ensure that a {@link Memoizer} allows re-entrant computation of the same keyed value. - */ - @Test - void shouldAllowReentrantUpdatesForSameHashCode() { - - final var first = new TestObject("test", 1); - final var second = new TestObject("test", 2); - final var third = new TestObject("test", 3); - - final var count = new AtomicInteger(0); - - // define a Lazy we can use in our reentrant function - final var lazyMemoizer = Lazy.>empty(); - - // define a reentrant function that uses the Lazy - final var reentrant = new Function() { - @Override - public String apply(final TestObject testObject) { - count.incrementAndGet(); - - return testObject.value == 1 - ? lazyMemoizer.orElseThrow() - .compute(second) - : "hello"; - } - }; - - // now create the Memoizer using our reentrant function - lazyMemoizer.set(new Memoizer<>(reentrant)); - - // ensure we haven't computed anything yet - assertThat(count) - .hasValue(0); - - // ensure we can compute the third value (not re-entrant) - assertThat(lazyMemoizer.orElseThrow() - .compute(third)) - .isEqualTo("hello"); - - // ensure we computed only one value - assertThat(count) - .hasValue(1); - - // ensure we can compute the first value (re-entrant) - assertThat(lazyMemoizer.orElseThrow() - .compute(first)) - .isEqualTo("hello"); - - // ensure we computed two more values (as the first value computation is re-entrant) - assertThat(count) - .hasValue(3); - } - - - /** - * Test object for complex object testing. - */ - private record TestObject(String name, int value) { - - - @Override - public int hashCode() { - return Objects.hash(this.name); - } - - @Override - public boolean equals(Object obj) { - if (this == obj) return true; - return obj instanceof TestObject(String thatName, int thatValue) && - this.value == thatValue && - this.name.equals(thatName); - } - } -} diff --git a/base-foundation/src/test/java/build/base/foundation/memoizer/MemoizerTests.java b/base-foundation/src/test/java/build/base/foundation/memoizer/MemoizerTests.java new file mode 100644 index 0000000..8359f07 --- /dev/null +++ b/base-foundation/src/test/java/build/base/foundation/memoizer/MemoizerTests.java @@ -0,0 +1,246 @@ +package build.base.foundation.memoizer; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +import build.base.foundation.Lazy; + +import java.util.HashMap; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Function; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Tests for the {@link Memoizer} hierarchy: {@link StrongMemoizer}, {@link SoftMemoizer}, + * {@link WeakMemoizer}, {@link StrongConcurrentMemoizer}, {@link SoftConcurrentMemoizer}, + * and {@link WeakConcurrentMemoizer}. + * + * @author reed.vonredwitz + * @since Jul-2025 + */ +class MemoizerTests { + + // ------------------------------------------------------------------------- + // Parameterized helpers — one factory per implementation + // ------------------------------------------------------------------------- + + static Stream, Memoizer>> allFactories() { + return Stream.of( + fn -> Memoizer.of(fn).build(), + fn -> Memoizer.of(fn).soft().build(), + fn -> Memoizer.of(fn).weak().build(), + fn -> Memoizer.of(fn).concurrent().build(), + fn -> Memoizer.of(fn).soft().concurrent().build(), + fn -> Memoizer.of(fn).weak().concurrent().build() + ); + } + + static Stream, Memoizer>> concurrentFactories() { + return Stream.of( + fn -> Memoizer.of(fn).concurrent().build(), + fn -> Memoizer.of(fn).soft().concurrent().build(), + fn -> Memoizer.of(fn).weak().concurrent().build() + ); + } + + // ------------------------------------------------------------------------- + // Core contract (all six implementations) + // ------------------------------------------------------------------------- + + @ParameterizedTest + @MethodSource("allFactories") + void shouldComputeOnFirstCall(final Function, Memoizer> factory) { + final var memoizer = factory.apply(s -> s == null ? -1 : s.length()); + assertThat(memoizer.compute("hello")).isEqualTo(5); + } + + @ParameterizedTest + @MethodSource("allFactories") + void shouldReturnCachedResultOnSubsequentCalls(final Function, Memoizer> factory) { + final var count = new AtomicInteger(); + final var memoizer = factory.apply(s -> { count.incrementAndGet(); return s.length(); }); + + memoizer.compute("hello"); + memoizer.compute("hello"); + memoizer.compute("hello"); + + assertThat(count).hasValue(1); + } + + @ParameterizedTest + @MethodSource("allFactories") + void shouldComputeSeparateResultsForDifferentInputs(final Function, Memoizer> factory) { + final var memoizer = factory.apply(String::length); + assertThat(memoizer.compute("hi")).isEqualTo(2); + assertThat(memoizer.compute("hello")).isEqualTo(5); + assertThat(memoizer.size()).isEqualTo(2); + } + + @ParameterizedTest + @MethodSource("allFactories") + void shouldSupportNullInput(final Function, Memoizer> factory) { + final var memoizer = factory.apply(s -> s == null ? -1 : s.length()); + assertThat(memoizer.compute(null)).isEqualTo(-1); + assertThat(memoizer.contains(null)).isTrue(); + } + + @ParameterizedTest + @MethodSource("allFactories") + void shouldSupportNullResult(final Function, Memoizer> factory) { + final var count = new AtomicInteger(); + final var memoizer = factory.apply(s -> { count.incrementAndGet(); return null; }); + + assertThat(memoizer.compute("key")).isNull(); + assertThat(memoizer.compute("key")).isNull(); + assertThat(count).hasValue(1); + } + + @ParameterizedTest + @MethodSource("allFactories") + void shouldClearAllCachedResults(final Function, Memoizer> factory) { + final var memoizer = factory.apply(String::length); + memoizer.compute("hello"); + assertThat(memoizer.size()).isGreaterThanOrEqualTo(1); + + memoizer.clear(); + assertThat(memoizer.size()).isEqualTo(0); + assertThat(memoizer.contains("hello")).isFalse(); + } + + @ParameterizedTest + @MethodSource("allFactories") + void shouldReportContainsCorrectly(final Function, Memoizer> factory) { + final var memoizer = factory.apply(String::length); + assertThat(memoizer.contains("hello")).isFalse(); + memoizer.compute("hello"); + assertThat(memoizer.contains("hello")).isTrue(); + assertThat(memoizer.contains("world")).isFalse(); + } + + @ParameterizedTest + @MethodSource("allFactories") + void shouldReportCorrectSize(final Function, Memoizer> factory) { + final var memoizer = factory.apply(String::length); + assertThat(memoizer.size()).isEqualTo(0); + memoizer.compute("a"); + memoizer.compute("bb"); + memoizer.compute("a"); + assertThat(memoizer.size()).isEqualTo(2); + } + + // ------------------------------------------------------------------------- + // Construction + // ------------------------------------------------------------------------- + + @Test + void shouldRejectNullFunction() { + assertThatThrownBy(() -> Memoizer.of(null)) + .isInstanceOf(NullPointerException.class); + } + + @Test + void shouldAcceptCustomMapSupplier() { + final var memoizer = Memoizer.of(String::length).withMap(HashMap::new).build(); + assertThat(memoizer.compute("hello")).isEqualTo(5); + } + + @Test + void shouldRejectNonConcurrentMapForConcurrentMemoizer() { + assertThatThrownBy(() -> Memoizer.of(String::length).withMap(HashMap::new).concurrent().build()) + .isInstanceOf(IllegalArgumentException.class); + } + + // ------------------------------------------------------------------------- + // Thread safety (concurrent implementations) + // ------------------------------------------------------------------------- + + @ParameterizedTest + @MethodSource("concurrentFactories") + void shouldComputeAtMostOnceUnderConcurrentLoad( + final Function, Memoizer> factory) throws InterruptedException { + final var count = new AtomicInteger(); + final var memoizer = factory.apply(s -> { count.incrementAndGet(); sleep10ms(); return s.length(); }); + + final var threads = new Thread[20]; + for (int i = 0; i < threads.length; i++) { + threads[i] = new Thread(() -> { + for (int j = 0; j < 50; j++) { + memoizer.compute("concurrent"); + } + }); + } + for (final var t : threads) t.start(); + for (final var t : threads) t.join(); + + assertThat(count).hasValue(1); + assertThat(memoizer.size()).isEqualTo(1); + } + + private static void sleep10ms() { + try { Thread.sleep(10); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } + } + + // ------------------------------------------------------------------------- + // Re-entrancy (concurrent implementations — different keys, same hash code) + // ------------------------------------------------------------------------- + + /** + * Guards against regression to {@code ConcurrentHashMap.computeIfAbsent}: verifies that when + * a memoized function calls back into the same memoizer for a key that shares a hash code with + * the in-progress key, the computation completes correctly without deadlock. + *

+ * {@code computeIfAbsent} on the cache map would fail here because it holds the map's bin lock + * during computation; the re-entrant call for a key in the same bin would attempt to re-acquire + * that bin lock, causing either a deadlock or an {@link IllegalStateException}. The per-key + * {@link java.util.concurrent.locks.ReentrantLock} approach avoids this: no bin lock is held + * during computation, so re-entrant calls always succeed. + */ + @Test + void shouldSupportReentrantComputationForKeysWithSameHashCode() { + verifyReentrantComputation(fn -> Memoizer.of(fn).concurrent().build()); + verifyReentrantComputation(fn -> Memoizer.of(fn).soft().concurrent().build()); + verifyReentrantComputation(fn -> Memoizer.of(fn).weak().concurrent().build()); + } + + private void verifyReentrantComputation( + final Function, Memoizer> factory) + { + final var count = new AtomicInteger(); + final var lazy = Lazy.>empty(); + + lazy.set(factory.apply((TestKey key) -> { + count.incrementAndGet(); + return key.value() == 1 + ? lazy.orElseThrow().compute(new TestKey("test", 2)) + : "hello"; + })); + + assertThat(lazy.orElseThrow().compute(new TestKey("test", 3))).isEqualTo("hello"); + assertThat(count).hasValue(1); + + assertThat(lazy.orElseThrow().compute(new TestKey("test", 1))).isEqualTo("hello"); + assertThat(count).hasValue(3); + } + + // ------------------------------------------------------------------------- + // Test helpers + // ------------------------------------------------------------------------- + + private record TestKey(String name, int value) { + + @Override + public int hashCode() { + // All TestKeys with the same name share a hash — exercises same-stripe scenarios. + return name.hashCode(); + } + + @Override + public boolean equals(final Object obj) { + return obj instanceof TestKey(String n, int v) && this.value == v && this.name.equals(n); + } + } +} diff --git a/base-query/src/main/java/build/base/query/AbstractHeapBasedIndex.java b/base-query/src/main/java/build/base/query/AbstractHeapBasedIndex.java index ac5eb28..31e81d1 100644 --- a/base-query/src/main/java/build/base/query/AbstractHeapBasedIndex.java +++ b/base-query/src/main/java/build/base/query/AbstractHeapBasedIndex.java @@ -21,7 +21,7 @@ */ import build.base.foundation.Introspection; -import build.base.foundation.Memoizer; +import build.base.foundation.memoizer.Memoizer; import build.base.foundation.stream.Streamable; import build.base.foundation.tuple.Pair; @@ -130,12 +130,12 @@ protected AbstractHeapBasedIndex() { this.objectByClass = new ConcurrentHashMap<>(); this.objectsByClassIndexableFunctionAndValue = new ConcurrentHashMap<>(); this.uniqueObjectsByClassFunctionAndKey = new ConcurrentHashMap<>(); - this.indexableFunctionsByClass = new Memoizer<>(AbstractHeapBasedIndex::resolveIndexableFunctions); - this.uniqueIndexableFunctionsByClass = new Memoizer<>(AbstractHeapBasedIndex::resolveUniqueIndexableFunctions); - this.dynamicNonUniqueFunctionsByClass = new Memoizer<>(AbstractHeapBasedIndex::resolveDynamicNonUniqueFunctions); - this.dynamicUniqueFunctionsByClass = new Memoizer<>(AbstractHeapBasedIndex::resolveDynamicUniqueFunctions); - this.eachFunctionsByClass = new Memoizer<>(AbstractHeapBasedIndex::resolveEachFunctions); - this.dynamicEachFunctionsByClass = new Memoizer<>(AbstractHeapBasedIndex::resolveDynamicEachFunctions); + this.indexableFunctionsByClass = Memoizer.of(AbstractHeapBasedIndex::resolveIndexableFunctions).concurrent().build(); + this.uniqueIndexableFunctionsByClass = Memoizer.of(AbstractHeapBasedIndex::resolveUniqueIndexableFunctions).concurrent().build(); + this.dynamicNonUniqueFunctionsByClass = Memoizer.of(AbstractHeapBasedIndex::resolveDynamicNonUniqueFunctions).concurrent().build(); + this.dynamicUniqueFunctionsByClass = Memoizer.of(AbstractHeapBasedIndex::resolveDynamicUniqueFunctions).concurrent().build(); + this.eachFunctionsByClass = Memoizer.of(AbstractHeapBasedIndex::resolveEachFunctions).concurrent().build(); + this.dynamicEachFunctionsByClass = Memoizer.of(AbstractHeapBasedIndex::resolveDynamicEachFunctions).concurrent().build(); } @Override