This is an automated email from the ASF dual-hosted git repository. reschke pushed a commit to branch OAK-11852b in repository https://gitbox.apache.org/repos/asf/jackrabbit-oak.git
commit 8c2d706f25faa6c2040e12b92279180ba278081a Author: Julian Reschke <[email protected]> AuthorDate: Tue Aug 12 14:26:34 2025 +0100 OAK-11852: add missing test class --- .../cache/CacheChangesTrackerConcurrencyTest.java | 215 +++++++++++++++++++++ 1 file changed, 215 insertions(+) diff --git a/oak-store-document/src/test/java/org/apache/jackrabbit/oak/plugins/document/cache/CacheChangesTrackerConcurrencyTest.java b/oak-store-document/src/test/java/org/apache/jackrabbit/oak/plugins/document/cache/CacheChangesTrackerConcurrencyTest.java new file mode 100644 index 0000000000..e04849a880 --- /dev/null +++ b/oak-store-document/src/test/java/org/apache/jackrabbit/oak/plugins/document/cache/CacheChangesTrackerConcurrencyTest.java @@ -0,0 +1,215 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * 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. + */ +package org.apache.jackrabbit.oak.plugins.document.cache; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.*; + +/** + * Tests for CacheChangesTracker concurrency scenarios, particularly + * the LazyBloomFilter double-checked locking implementation. + */ +public class CacheChangesTrackerConcurrencyTest { + + /** + * Test concurrent initialization of LazyBloomFilter to ensure + * double-checked locking prevents race conditions. + */ + @Test + public void testLazyBloomFilterConcurrentInitialization() throws InterruptedException { + final int threadCount = 20; + final int entriesPerThread = 50; + final CountDownLatch startLatch = new CountDownLatch(1); + final CountDownLatch doneLatch = new CountDownLatch(threadCount); + final ExecutorService executor = Executors.newFixedThreadPool(threadCount); + + // Create a LazyBloomFilter instance + final CacheChangesTracker.LazyBloomFilter lazyFilter = + new CacheChangesTracker.LazyBloomFilter(1000); + + final AtomicInteger putOperations = new AtomicInteger(0); + final List<Exception> exceptions = Collections.synchronizedList(new ArrayList<>()); + + try { + // Create multiple threads that will all try to initialize and use the filter simultaneously + for (int i = 0; i < threadCount; i++) { + final int threadId = i; + executor.submit(() -> { + try { + // Wait for all threads to be ready + startLatch.await(); + + // Each thread adds multiple entries + for (int j = 0; j < entriesPerThread; j++) { + String key = "thread-" + threadId + "-key-" + j; + lazyFilter.put(key); + putOperations.incrementAndGet(); + + // Add a small random delay to increase chance of race condition + if (j % 10 == 0) { + Thread.sleep(1); + } + } + } catch (Exception e) { + exceptions.add(e); + } finally { + doneLatch.countDown(); + } + }); + } + + // Start all threads simultaneously + startLatch.countDown(); + + // Wait for all threads to complete + assertTrue("Test timed out", doneLatch.await(30, TimeUnit.SECONDS)); + + // Verify no exceptions occurred + if (!exceptions.isEmpty()) { + fail("Exceptions occurred during concurrent access: " + exceptions.get(0)); + } + + // Verify all put operations completed + assertEquals(threadCount * entriesPerThread, putOperations.get()); + + // Verify the filter works correctly after concurrent initialization + for (int i = 0; i < threadCount; i++) { + for (int j = 0; j < entriesPerThread; j++) { + String key = "thread-" + i + "-key-" + j; + assertTrue("Filter should contain key: " + key, lazyFilter.mightContain(key)); + } + } + + // Verify false positive behavior (some keys that weren't added should return false) + int falsePositives = 0; + int testKeys = 100; + for (int i = 0; i < testKeys; i++) { + String nonExistentKey = "non-existent-key-" + i; + if (lazyFilter.mightContain(nonExistentKey)) { + falsePositives++; + } + } + + // With 1000 entries and 1% FPP, we expect roughly 1% false positives for non-existent keys + // Allow for some variance but it shouldn't be too high + assertTrue("False positive rate too high: " + falsePositives + "/" + testKeys, + falsePositives < testKeys * 0.05); // Allow up to 5% to account for variance + + } finally { + executor.shutdown(); + if (!executor.awaitTermination(5, TimeUnit.SECONDS)) { + executor.shutdownNow(); + } + } + } + + /** + * Test concurrent put and mightContain operations to ensure thread safety. + */ + @Test + public void testLazyBloomFilterConcurrentReadWrite() throws InterruptedException { + final int threadCount = 10; + final int operationsPerThread = 100; + final CountDownLatch startLatch = new CountDownLatch(1); + final CountDownLatch doneLatch = new CountDownLatch(threadCount); + final ExecutorService executor = Executors.newFixedThreadPool(threadCount); + + final CacheChangesTracker.LazyBloomFilter lazyFilter = + new CacheChangesTracker.LazyBloomFilter(2000); + + final AtomicInteger readOperations = new AtomicInteger(0); + final AtomicInteger writeOperations = new AtomicInteger(0); + final List<Exception> exceptions = Collections.synchronizedList(new ArrayList<>()); + + try { + // Create mixed read/write threads + for (int i = 0; i < threadCount; i++) { + final int threadId = i; + final boolean isWriter = (i % 2 == 0); + + executor.submit(() -> { + try { + startLatch.await(); + + for (int j = 0; j < operationsPerThread; j++) { + String key = "mixed-thread-" + threadId + "-key-" + j; + + if (isWriter || j < 10) { // Writers, or first few operations of readers + lazyFilter.put(key); + writeOperations.incrementAndGet(); + } + + // All threads also do reads + boolean result = lazyFilter.mightContain(key); + readOperations.incrementAndGet(); + + // If we just wrote the key, it should definitely be found + if (isWriter || j < 10) { + assertTrue("Key should be found after being added: " + key, result); + } + } + } catch (Exception e) { + exceptions.add(e); + } finally { + doneLatch.countDown(); + } + }); + } + + startLatch.countDown(); + assertTrue("Test timed out", doneLatch.await(30, TimeUnit.SECONDS)); + + if (!exceptions.isEmpty()) { + fail("Exceptions occurred during concurrent read/write: " + exceptions.get(0)); + } + + assertTrue("Should have performed read operations", readOperations.get() > 0); + assertTrue("Should have performed write operations", writeOperations.get() > 0); + + } finally { + executor.shutdown(); + if (!executor.awaitTermination(5, TimeUnit.SECONDS)) { + executor.shutdownNow(); + } + } + } + + /** + * Test that LazyBloomFilter behaves correctly when filter is never initialized + * (i.e., only mightContain is called, never put). + */ + @Test + public void testLazyBloomFilterNoInitialization() { + CacheChangesTracker.LazyBloomFilter lazyFilter = + new CacheChangesTracker.LazyBloomFilter(1000); + + // Should return false for any key when filter is not initialized + assertFalse(lazyFilter.mightContain("any-key")); + assertFalse(lazyFilter.mightContain("another-key")); + assertFalse(lazyFilter.mightContain("")); + } +}
