From 75d31aeda5e2ab8c28dfeb3b0eb5b0933fab9ae0 Mon Sep 17 00:00:00 2001
From: Leo Galambos
Date: Mon, 27 Jul 2026 19:47:52 +0200
Subject: [PATCH] perf(trie): compute compiled trie fingerprints lazily
---
.../org/egothor/stemmer/FrequencyTrie.java | 59 ++-
.../egothor/stemmer/FrequencyTrieTest.java | 338 ++++++++++++++++++
2 files changed, 391 insertions(+), 6 deletions(-)
diff --git a/src/main/java/org/egothor/stemmer/FrequencyTrie.java b/src/main/java/org/egothor/stemmer/FrequencyTrie.java
index 0092eb4..a59f90b 100644
--- a/src/main/java/org/egothor/stemmer/FrequencyTrie.java
+++ b/src/main/java/org/egothor/stemmer/FrequencyTrie.java
@@ -47,6 +47,7 @@ import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
+import java.util.concurrent.locks.ReentrantLock;
import java.util.function.IntFunction;
import java.util.logging.Level;
import java.util.logging.Logger;
@@ -118,10 +119,19 @@ public final class FrequencyTrie {
private final TrieMetadata metadata;
/**
- * Canonical SHA-256 fingerprint bytes. The internal array is never exposed
- * directly to callers.
+ * Lazily initialized canonical SHA-256 fingerprint bytes. Every read and write
+ * is guarded by {@link #fingerprintLock}. The value is assigned only after
+ * calculation succeeds, so failed initialization leaves the cache empty. The
+ * internal array is never exposed directly to callers.
*/
- private final byte[] fingerprintBytes;
+ private byte[] fingerprintBytes;
+
+ /**
+ * Guards all access to {@link #fingerprintBytes}. Holding this lock for every
+ * cache read and write safely publishes successful lazy initialization to
+ * subsequent callers.
+ */
+ private final ReentrantLock fingerprintLock = new ReentrantLock();
/**
* Cached traversal direction used for key lookup.
@@ -263,7 +273,6 @@ public final class FrequencyTrie {
final TrieMetadata metadata) {
this.root = Objects.requireNonNull(root, "root");
this.metadata = Objects.requireNonNull(metadata, "metadata");
- this.fingerprintBytes = computeFingerprintBytes(root, metadata);
this.lookupTraversalDirection = metadata.traversalDirection();
this.lowercasesLookupKeys = metadata.caseProcessingMode() == CaseProcessingMode.LOWERCASE_WITH_LOCALE_ROOT;
this.removeDiacritics = metadata.diacriticProcessingMode() == DiacriticProcessingMode.REMOVE;
@@ -623,10 +632,15 @@ public final class FrequencyTrie {
* state, absolute file paths, timestamps, or other process-local state.
*
*
+ *
+ * The fingerprint is calculated on first request and reused by later
+ * fingerprint accessors.
+ *
+ *
* @return 64-character lowercase hexadecimal SHA-256 fingerprint
*/
public String getFingerprint() {
- return toLowerHex(this.fingerprintBytes);
+ return toLowerHex(fingerprintBytes());
}
/**
@@ -637,10 +651,43 @@ public final class FrequencyTrie {
* trie.
*
*
+ *
+ * The fingerprint is calculated on first request and reused by later
+ * fingerprint accessors.
+ *
+ *
* @return defensive copy of the 32-byte SHA-256 fingerprint
*/
public byte[] copyFingerprintBytes() {
- return Arrays.copyOf(this.fingerprintBytes, this.fingerprintBytes.length);
+ final byte[] localFingerprintBytes = fingerprintBytes();
+ return Arrays.copyOf(localFingerprintBytes, localFingerprintBytes.length);
+ }
+
+ /**
+ * Returns the cached raw SHA-256 fingerprint bytes, computing them on the first
+ * request.
+ *
+ *
+ * Access to the cache is guarded by {@link #fingerprintLock}. Because every read
+ * and write of the cache occurs while holding the same lock, successful
+ * initialization is safely published to all subsequent callers. If calculation
+ * fails, no value is cached and a later call may retry.
+ *
+ *
+ * @return internal cached raw SHA-256 fingerprint bytes
+ */
+ private byte[] fingerprintBytes() {
+ this.fingerprintLock.lock();
+ try {
+ byte[] localFingerprintBytes = this.fingerprintBytes;
+ if (localFingerprintBytes == null) {
+ localFingerprintBytes = computeFingerprintBytes(this.root, this.metadata);
+ this.fingerprintBytes = localFingerprintBytes;
+ }
+ return localFingerprintBytes;
+ } finally {
+ this.fingerprintLock.unlock();
+ }
}
private static byte[] computeFingerprintBytes(final CompiledNode root, final TrieMetadata metadata) {
diff --git a/src/test/java/org/egothor/stemmer/FrequencyTrieTest.java b/src/test/java/org/egothor/stemmer/FrequencyTrieTest.java
index a55e5e1..f0b8917 100644
--- a/src/test/java/org/egothor/stemmer/FrequencyTrieTest.java
+++ b/src/test/java/org/egothor/stemmer/FrequencyTrieTest.java
@@ -46,8 +46,18 @@ import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashSet;
import java.util.List;
+import java.util.Set;
+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 org.egothor.stemmer.trie.CompiledNode;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
@@ -972,6 +982,167 @@ class FrequencyTrieTest {
() -> assertFalse(fingerprintA.equals(differentMetadataFingerprint)));
}
+ /**
+ * Verifies that trie construction does not calculate the canonical fingerprint.
+ */
+ @Test
+ @Tag("fingerprint")
+ @DisplayName("Construction does not calculate fingerprint")
+ void constructionDoesNotCalculateFingerprint() {
+ final AtomicInteger toStringCalls = new AtomicInteger();
+ final ObservableValue value = new ObservableValue("observed", toStringCalls);
+
+ directCompiledObservableTrie(value);
+
+ assertEquals(0, toStringCalls.get());
+ }
+
+ /**
+ * Verifies that ordinary read-only trie operations do not calculate the
+ * canonical fingerprint.
+ */
+ @Test
+ @Tag("fingerprint")
+ @DisplayName("Ordinary operations do not calculate fingerprint")
+ void ordinaryOperationsDoNotCalculateFingerprint() {
+ final AtomicInteger toStringCalls = new AtomicInteger();
+ final ObservableValue value = new ObservableValue("observed", toStringCalls);
+ final FrequencyTrie trie = observableTrie(value);
+ toStringCalls.set(0);
+
+ final ObservableValue preferred = trie.get("alpha");
+ final ObservableValue[] allValues = trie.getAll("alpha");
+ final List> entries = trie.getEntries("alpha");
+ final int trieSize = trie.size();
+ final TrieMetadata metadata = trie.metadata();
+ final WordTraversalDirection traversalDirection = trie.traversalDirection();
+
+ assertEquals(0, toStringCalls.get());
+ assertAll(() -> assertSame(value, preferred),
+ () -> assertEquals(1, allValues.length),
+ () -> assertSame(value, allValues[0]),
+ () -> assertEquals(1, entries.size()),
+ () -> assertSame(value, entries.get(0).value()),
+ () -> assertEquals(1, entries.get(0).count()),
+ () -> assertTrue(trieSize > 0),
+ () -> assertEquals(metadata.traversalDirection(), traversalDirection));
+ }
+
+ /**
+ * Verifies that the first string fingerprint request computes and caches the
+ * canonical digest.
+ */
+ @Test
+ @Tag("fingerprint")
+ @DisplayName("First getFingerprint calculates and caches fingerprint")
+ void firstGetFingerprintCalculatesAndCachesFingerprint() {
+ final AtomicInteger toStringCalls = new AtomicInteger();
+ final ObservableValue value = new ObservableValue("observed", toStringCalls);
+ final FrequencyTrie trie = observableTrie(value);
+ toStringCalls.set(0);
+
+ final String firstFingerprint = trie.getFingerprint();
+ final int callsAfterFirstFingerprint = toStringCalls.get();
+ final String secondFingerprint = trie.getFingerprint();
+ final byte[] fingerprintBytes = trie.copyFingerprintBytes();
+
+ assertAll(() -> assertTrue(callsAfterFirstFingerprint > 0),
+ () -> assertEquals(callsAfterFirstFingerprint, toStringCalls.get()),
+ () -> assertEquals(firstFingerprint, secondFingerprint),
+ () -> assertEquals(firstFingerprint, toLowerHex(fingerprintBytes)));
+ }
+
+ /**
+ * Verifies that the raw-byte accessor can initialize the canonical digest cache
+ * and still returns defensive copies.
+ */
+ @Test
+ @Tag("fingerprint")
+ @DisplayName("copyFingerprintBytes can initialize fingerprint cache")
+ void copyFingerprintBytesCanInitializeFingerprintCache() {
+ final AtomicInteger toStringCalls = new AtomicInteger();
+ final ObservableValue value = new ObservableValue("observed", toStringCalls);
+ final FrequencyTrie trie = observableTrie(value);
+ toStringCalls.set(0);
+
+ final byte[] firstFingerprintBytes = trie.copyFingerprintBytes();
+ final int callsAfterFirstCopy = toStringCalls.get();
+ final String expectedFingerprint = toLowerHex(firstFingerprintBytes);
+ firstFingerprintBytes[0] = (byte) (firstFingerprintBytes[0] ^ 0x7F);
+
+ final byte[] secondFingerprintBytes = trie.copyFingerprintBytes();
+ final String fingerprint = trie.getFingerprint();
+
+ assertAll(() -> assertTrue(callsAfterFirstCopy > 0),
+ () -> assertEquals(32, firstFingerprintBytes.length),
+ () -> assertEquals(callsAfterFirstCopy, toStringCalls.get()),
+ () -> assertEquals(expectedFingerprint, toLowerHex(secondFingerprintBytes)),
+ () -> assertEquals(expectedFingerprint, fingerprint),
+ () -> assertFalse(Arrays.equals(firstFingerprintBytes, secondFingerprintBytes)));
+ }
+
+ /**
+ * Verifies that concurrent first fingerprint access performs one digest
+ * calculation and publishes the same result to all readers.
+ *
+ * @throws Exception if the worker coordination fails unexpectedly
+ */
+ @Test
+ @Tag("fingerprint")
+ @DisplayName("Concurrent first access calculates fingerprint once")
+ void concurrentFirstAccessCalculatesFingerprintOnce() throws Exception {
+ final AtomicInteger toStringCalls = new AtomicInteger();
+ final ObservableValue value = new ObservableValue("observed", toStringCalls);
+ final FrequencyTrie trie = observableTrie(value);
+ final CountDownLatch startWorkers = new CountDownLatch(1);
+ final CountDownLatch firstToStringEntered = new CountDownLatch(1);
+ final CountDownLatch releaseFirstToString = new CountDownLatch(1);
+ final int workerCount = 8;
+ final ExecutorService executorService = Executors.newFixedThreadPool(workerCount);
+ final List> futures = new ArrayList<>(workerCount);
+ toStringCalls.set(0);
+ value.blockFirstToStringInvocation(firstToStringEntered, releaseFirstToString);
+
+ try {
+ for (int workerIndex = 0; workerIndex < workerCount; workerIndex++) {
+ final int fingerprintAccessIndex = workerIndex;
+ futures.add(executorService.submit(() -> {
+ startWorkers.await();
+ if (fingerprintAccessIndex % 2 == 0) {
+ return trie.getFingerprint();
+ }
+ return toLowerHex(trie.copyFingerprintBytes());
+ }));
+ }
+
+ startWorkers.countDown();
+ assertTrue(firstToStringEntered.await(5, TimeUnit.SECONDS),
+ "Timed out waiting for the first fingerprint calculation to start.");
+ releaseFirstToString.countDown();
+
+ final Set observedFingerprints = new HashSet<>();
+ observedFingerprints.add(futures.get(0).get(5, TimeUnit.SECONDS));
+ final int callsAfterFirstCompletedAccess = toStringCalls.get();
+ for (int index = 1; index < futures.size(); index++) {
+ observedFingerprints.add(futures.get(index).get(5, TimeUnit.SECONDS));
+ }
+
+ final String fingerprint = trie.getFingerprint();
+ final byte[] fingerprintBytes = trie.copyFingerprintBytes();
+
+ assertAll(() -> assertEquals(1, observedFingerprints.size()),
+ () -> assertTrue(callsAfterFirstCompletedAccess > 0),
+ () -> assertEquals(callsAfterFirstCompletedAccess, toStringCalls.get()),
+ () -> assertTrue(observedFingerprints.contains(fingerprint)),
+ () -> assertEquals(fingerprint, toLowerHex(fingerprintBytes)));
+ } finally {
+ releaseFirstToString.countDown();
+ executorService.shutdownNow();
+ assertTrue(executorService.awaitTermination(5, TimeUnit.SECONDS),
+ "Timed out waiting for fingerprint worker shutdown.");
+ }
+ }
+
private static void assertLowercaseSha256Hex(final String fingerprint) {
assertEquals(64, fingerprint.length());
for (int index = 0; index < fingerprint.length(); index++) {
@@ -990,6 +1161,173 @@ class FrequencyTrieTest {
}
return builder.toString();
}
+
+ /**
+ * Builds a small trie containing one observable value.
+ *
+ * @param value observable value to store
+ * @return compiled trie containing {@code value}
+ */
+ private static FrequencyTrie observableTrie(final ObservableValue value) {
+ final FrequencyTrie.Builder builder = new FrequencyTrie.Builder<>(ObservableValue[]::new,
+ ReductionMode.MERGE_SUBTREES_WITH_EQUIVALENT_RANKED_GET_ALL_RESULTS);
+ builder.put("alpha", value);
+ return builder.build();
+ }
+
+ /**
+ * Builds a one-node compiled trie directly so constructor-time fingerprinting is
+ * observable without build-time value ordering.
+ *
+ * @param value observable value to store at the root
+ * @return compiled trie containing {@code value}
+ */
+ @SuppressWarnings("unchecked")
+ private static FrequencyTrie directCompiledObservableTrie(final ObservableValue value) {
+ final CompiledNode[] children = new CompiledNode[0];
+ final CompiledNode root = new CompiledNode<>(new char[0], children,
+ new ObservableValue[] { value }, new int[] { 1 });
+ final TrieMetadata metadata = TrieMetadata.forCompilation(WordTraversalDirection.BACKWARD,
+ ReductionSettings.withDefaults(ReductionMode.MERGE_SUBTREES_WITH_EQUIVALENT_RANKED_GET_ALL_RESULTS),
+ DiacriticProcessingMode.AS_IS, CaseProcessingMode.LOWERCASE_WITH_LOCALE_ROOT);
+ return FrequencyTrie.fromCompiled(ObservableValue[]::new, root, metadata);
+ }
+
+ /**
+ * Test value whose textual representation records every invocation.
+ */
+ private static final class ObservableValue {
+
+ /**
+ * Deterministic text returned by {@link #toString()}.
+ */
+ private final String text;
+
+ /**
+ * Invocation counter owned by the current test.
+ */
+ private final AtomicInteger toStringCalls;
+
+ /**
+ * Optional first-invocation blocker used by the concurrency test.
+ */
+ private volatile FirstToStringBlocker firstToStringBlocker;
+
+ /**
+ * Creates an observable value.
+ *
+ * @param text deterministic textual representation
+ * @param toStringCalls invocation counter
+ */
+ ObservableValue(final String text, final AtomicInteger toStringCalls) {
+ this.text = text;
+ this.toStringCalls = toStringCalls;
+ }
+
+ /**
+ * Blocks the next {@link #toString()} invocation until the supplied release
+ * latch opens.
+ *
+ * @param entered latch counted down when the invocation reaches the blocker
+ * @param release latch that releases the blocked invocation
+ */
+ void blockFirstToStringInvocation(final CountDownLatch entered, final CountDownLatch release) {
+ this.firstToStringBlocker = new FirstToStringBlocker(entered, release);
+ }
+
+ /**
+ * Returns the deterministic value text while recording the invocation.
+ *
+ * @return deterministic value text
+ */
+ @Override
+ public String toString() {
+ this.toStringCalls.incrementAndGet();
+ final FirstToStringBlocker blocker = this.firstToStringBlocker;
+ if (blocker != null) {
+ blocker.blockFirstInvocation();
+ }
+ return this.text;
+ }
+
+ /**
+ * Compares observable values by deterministic text only.
+ *
+ * @param other other object
+ * @return {@code true} when both values have the same deterministic text
+ */
+ @Override
+ public boolean equals(final Object other) {
+ if (this == other) {
+ return true;
+ }
+ if (!(other instanceof ObservableValue)) {
+ return false;
+ }
+ final ObservableValue that = (ObservableValue) other;
+ return this.text.equals(that.text);
+ }
+
+ /**
+ * Returns a stable hash code based on deterministic text.
+ *
+ * @return stable hash code
+ */
+ @Override
+ public int hashCode() {
+ return this.text.hashCode();
+ }
+ }
+
+ /**
+ * One-shot latch pair used to hold the first observable {@code toString()}
+ * invocation inside fingerprint calculation.
+ */
+ private static final class FirstToStringBlocker {
+
+ /**
+ * Latch signaled when the first invocation reaches the blocker.
+ */
+ private final CountDownLatch entered;
+
+ /**
+ * Latch that releases the blocked invocation.
+ */
+ private final CountDownLatch release;
+
+ /**
+ * Ensures only one invocation blocks.
+ */
+ private final AtomicInteger blockClaims = new AtomicInteger();
+
+ /**
+ * Creates a one-shot blocker.
+ *
+ * @param entered latch signaled when blocking starts
+ * @param release latch that releases the blocked invocation
+ */
+ FirstToStringBlocker(final CountDownLatch entered, final CountDownLatch release) {
+ this.entered = entered;
+ this.release = release;
+ }
+
+ /**
+ * Blocks only the first caller until the release latch opens.
+ */
+ void blockFirstInvocation() {
+ if (this.blockClaims.compareAndSet(0, 1)) {
+ this.entered.countDown();
+ try {
+ this.release.await();
+ } catch (InterruptedException exception) {
+ Thread.currentThread().interrupt();
+ throw new IllegalStateException("Interrupted while coordinating observable toString().",
+ exception);
+ }
+ }
+ }
+ }
+
/**
* Verifies that persistence methods reject {@code null} arguments.
*