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. *