perf(trie): compute compiled trie fingerprints lazily
This commit is contained in:
@@ -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<V> {
|
||||
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<V> {
|
||||
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<V> {
|
||||
* state, absolute file paths, timestamps, or other process-local state.
|
||||
* </p>
|
||||
*
|
||||
* <p>
|
||||
* The fingerprint is calculated on first request and reused by later
|
||||
* fingerprint accessors.
|
||||
* </p>
|
||||
*
|
||||
* @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<V> {
|
||||
* trie.
|
||||
* </p>
|
||||
*
|
||||
* <p>
|
||||
* The fingerprint is calculated on first request and reused by later
|
||||
* fingerprint accessors.
|
||||
* </p>
|
||||
*
|
||||
* @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.
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* @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 <V> byte[] computeFingerprintBytes(final CompiledNode<V> root, final TrieMetadata metadata) {
|
||||
|
||||
@@ -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<ObservableValue> trie = observableTrie(value);
|
||||
toStringCalls.set(0);
|
||||
|
||||
final ObservableValue preferred = trie.get("alpha");
|
||||
final ObservableValue[] allValues = trie.getAll("alpha");
|
||||
final List<ValueCount<ObservableValue>> 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<ObservableValue> 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<ObservableValue> 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<ObservableValue> 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<Future<String>> 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<String> 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<ObservableValue> observableTrie(final ObservableValue value) {
|
||||
final FrequencyTrie.Builder<ObservableValue> 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<ObservableValue> directCompiledObservableTrie(final ObservableValue value) {
|
||||
final CompiledNode<ObservableValue>[] children = new CompiledNode[0];
|
||||
final CompiledNode<ObservableValue> 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.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user