From de55c0a1faf9cf01ed5e26780f7e011504c24eb3 Mon Sep 17 00:00:00 2001 From: rball11 Date: Thu, 19 Feb 2026 13:45:56 -0700 Subject: [PATCH 1/5] feat Enabled read/write lock for GitHubSanityCachedValue [2172](https://github.com/hub4j/github-api/issues/2172) --- .../github/GitHubSanityCachedValue.java | 27 ++++- .../github/GitHubSanityCachedValueTest.java | 107 ++++++++++++++++++ 2 files changed, 130 insertions(+), 4 deletions(-) create mode 100644 src/test/java/org/kohsuke/github/GitHubSanityCachedValueTest.java diff --git a/src/main/java/org/kohsuke/github/GitHubSanityCachedValue.java b/src/main/java/org/kohsuke/github/GitHubSanityCachedValue.java index b82ae79e6c..31475121a3 100644 --- a/src/main/java/org/kohsuke/github/GitHubSanityCachedValue.java +++ b/src/main/java/org/kohsuke/github/GitHubSanityCachedValue.java @@ -3,6 +3,8 @@ import org.kohsuke.github.function.SupplierThrows; import java.time.Instant; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantReadWriteLock; import java.util.function.Function; /** @@ -12,7 +14,10 @@ class GitHubSanityCachedValue { private long lastQueriedAtEpochSeconds = 0; private T lastResult = null; - private final Object lock = new Object(); + // Allow concurrent readers while a refresh is not needed. + private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock(); + private final Lock readLock = lock.readLock(); + private final Lock writeLock = lock.writeLock(); /** * Gets the value from the cache or calls the supplier if the cache is empty or out of date. @@ -26,13 +31,27 @@ class GitHubSanityCachedValue { * the exception thrown by the supplier if it fails. */ T get(Function isExpired, SupplierThrows query) throws E { - synchronized (lock) { - if (Instant.now().getEpochSecond() > lastQueriedAtEpochSeconds || isExpired.apply(lastResult)) { + readLock.lock(); + try { + boolean expired = Instant.now().getEpochSecond() > lastQueriedAtEpochSeconds || isExpired.apply(lastResult); + if (!expired) { + return lastResult; + } + } finally { + readLock.unlock(); + } + + writeLock.lock(); + try { + boolean stillExpired = Instant.now().getEpochSecond() > lastQueriedAtEpochSeconds || isExpired.apply(lastResult); + if (stillExpired) { lastResult = query.get(); lastQueriedAtEpochSeconds = Instant.now().getEpochSecond(); } + return lastResult; + } finally { + writeLock.unlock(); } - return lastResult; } /** diff --git a/src/test/java/org/kohsuke/github/GitHubSanityCachedValueTest.java b/src/test/java/org/kohsuke/github/GitHubSanityCachedValueTest.java new file mode 100644 index 0000000000..c1fe043eee --- /dev/null +++ b/src/test/java/org/kohsuke/github/GitHubSanityCachedValueTest.java @@ -0,0 +1,107 @@ +package org.kohsuke.github; + +import org.junit.Test; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.hamcrest.CoreMatchers.equalTo; +import static org.hamcrest.CoreMatchers.notNullValue; +import static org.hamcrest.MatcherAssert.assertThat; + +public class GitHubSanityCachedValueTest { + + @Test + public void cachesWithinSameSecond() throws Exception { + alignToStartOfSecond(); + GitHubSanityCachedValue cachedValue = new GitHubSanityCachedValue<>(); + AtomicInteger calls = new AtomicInteger(); + + String first = cachedValue.get(() -> { + calls.incrementAndGet(); + return "value"; + }); + String second = cachedValue.get(() -> { + calls.incrementAndGet(); + return "value"; + }); + + assertThat(first, equalTo("value")); + assertThat(second, equalTo("value")); + assertThat(calls.get(), equalTo(1)); + } + + @Test + public void refreshesAfterOneSecond() throws Exception { + GitHubSanityCachedValue cachedValue = new GitHubSanityCachedValue<>(); + AtomicInteger calls = new AtomicInteger(); + + String first = cachedValue.get(() -> { + calls.incrementAndGet(); + return "value"; + }); + + Thread.sleep(1100); + + String second = cachedValue.get(() -> { + calls.incrementAndGet(); + return "value"; + }); + + assertThat(first, equalTo("value")); + assertThat(second, equalTo("value")); + assertThat(calls.get(), equalTo(2)); + } + + @Test + public void concurrentCallersOnlyRefreshOnce() throws Exception { + alignToStartOfSecond(); + GitHubSanityCachedValue cachedValue = new GitHubSanityCachedValue<>(); + AtomicInteger calls = new AtomicInteger(); + List results = Collections.synchronizedList(new ArrayList<>()); + CountDownLatch ready = new CountDownLatch(5); + CountDownLatch start = new CountDownLatch(1); + CountDownLatch finished = new CountDownLatch(5); + + for (int i = 0; i < 5; i++) { + Thread thread = new Thread(() -> { + try { + ready.countDown(); + start.await(); + String value = cachedValue.get((result) -> result == null, () -> { + calls.incrementAndGet(); + return "value"; + }); + results.add(value); + } catch (Exception ignored) { + results.add(null); + } finally { + finished.countDown(); + } + }); + thread.start(); + } + + ready.await(); + start.countDown(); + finished.await(); + + assertThat(calls.get(), equalTo(1)); + assertThat(results.size(), equalTo(5)); + for (String result : results) { + assertThat(result, notNullValue()); + assertThat(result, equalTo("value")); + } + } + + private static void alignToStartOfSecond() { + while (Instant.now().getNano() > 100_000_000) { + Thread.yield(); + } + } +} + From cc772f459ffe41f461feb9922ccd65ca13c5ed7d Mon Sep 17 00:00:00 2001 From: rball11 Date: Fri, 27 Feb 2026 14:45:39 -0700 Subject: [PATCH 2/5] feat Enabled read/write lock for GitHubSanityCachedValue [2172](https://github.com/hub4j/github-api/issues/2172) --- .../github/GitHubSanityCachedValue.java | 1 - .../github/GitHubSanityCachedValueTest.java | 53 +++++++++---------- 2 files changed, 26 insertions(+), 28 deletions(-) diff --git a/src/main/java/org/kohsuke/github/GitHubSanityCachedValue.java b/src/main/java/org/kohsuke/github/GitHubSanityCachedValue.java index 31475121a3..acfdb094f1 100644 --- a/src/main/java/org/kohsuke/github/GitHubSanityCachedValue.java +++ b/src/main/java/org/kohsuke/github/GitHubSanityCachedValue.java @@ -40,7 +40,6 @@ T get(Function isExpired, SupplierThrows } finally { readLock.unlock(); } - writeLock.lock(); try { boolean stillExpired = Instant.now().getEpochSecond() > lastQueriedAtEpochSeconds || isExpired.apply(lastResult); diff --git a/src/test/java/org/kohsuke/github/GitHubSanityCachedValueTest.java b/src/test/java/org/kohsuke/github/GitHubSanityCachedValueTest.java index c1fe043eee..6facb6af21 100644 --- a/src/test/java/org/kohsuke/github/GitHubSanityCachedValueTest.java +++ b/src/test/java/org/kohsuke/github/GitHubSanityCachedValueTest.java @@ -15,6 +15,12 @@ public class GitHubSanityCachedValueTest { + private static void alignToStartOfSecond() { + while (Instant.now().getNano() > 100_000_000) { + Thread.yield(); + } + } + @Test public void cachesWithinSameSecond() throws Exception { alignToStartOfSecond(); @@ -35,28 +41,6 @@ public void cachesWithinSameSecond() throws Exception { assertThat(calls.get(), equalTo(1)); } - @Test - public void refreshesAfterOneSecond() throws Exception { - GitHubSanityCachedValue cachedValue = new GitHubSanityCachedValue<>(); - AtomicInteger calls = new AtomicInteger(); - - String first = cachedValue.get(() -> { - calls.incrementAndGet(); - return "value"; - }); - - Thread.sleep(1100); - - String second = cachedValue.get(() -> { - calls.incrementAndGet(); - return "value"; - }); - - assertThat(first, equalTo("value")); - assertThat(second, equalTo("value")); - assertThat(calls.get(), equalTo(2)); - } - @Test public void concurrentCallersOnlyRefreshOnce() throws Exception { alignToStartOfSecond(); @@ -98,10 +82,25 @@ public void concurrentCallersOnlyRefreshOnce() throws Exception { } } - private static void alignToStartOfSecond() { - while (Instant.now().getNano() > 100_000_000) { - Thread.yield(); - } + @Test + public void refreshesAfterOneSecond() throws Exception { + GitHubSanityCachedValue cachedValue = new GitHubSanityCachedValue<>(); + AtomicInteger calls = new AtomicInteger(); + + String first = cachedValue.get(() -> { + calls.incrementAndGet(); + return "value"; + }); + + Thread.sleep(1100); + + String second = cachedValue.get(() -> { + calls.incrementAndGet(); + return "value"; + }); + + assertThat(first, equalTo("value")); + assertThat(second, equalTo("value")); + assertThat(calls.get(), equalTo(2)); } } - From fe3f123f73671080f328ed0dd003375bd094e1af Mon Sep 17 00:00:00 2001 From: rball11 Date: Fri, 27 Feb 2026 14:47:55 -0700 Subject: [PATCH 3/5] feat Enabled read/write lock for GitHubSanityCachedValue [2172](https://github.com/hub4j/github-api/issues/2172) --- src/main/java/org/kohsuke/github/GitHubSanityCachedValue.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/kohsuke/github/GitHubSanityCachedValue.java b/src/main/java/org/kohsuke/github/GitHubSanityCachedValue.java index acfdb094f1..3e9eecd36d 100644 --- a/src/main/java/org/kohsuke/github/GitHubSanityCachedValue.java +++ b/src/main/java/org/kohsuke/github/GitHubSanityCachedValue.java @@ -42,7 +42,8 @@ T get(Function isExpired, SupplierThrows } writeLock.lock(); try { - boolean stillExpired = Instant.now().getEpochSecond() > lastQueriedAtEpochSeconds || isExpired.apply(lastResult); + boolean stillExpired = Instant.now().getEpochSecond() > lastQueriedAtEpochSeconds + || isExpired.apply(lastResult); if (stillExpired) { lastResult = query.get(); lastQueriedAtEpochSeconds = Instant.now().getEpochSecond(); From 2700ce7efaa00672bf2a4bf60b53808e133e7a3f Mon Sep 17 00:00:00 2001 From: rball11 Date: Fri, 27 Feb 2026 14:55:47 -0700 Subject: [PATCH 4/5] feat Enabled read/write lock for GitHubSanityCachedValue [2172](https://github.com/hub4j/github-api/issues/2172) --- .../github/GitHubSanityCachedValueTest.java | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/test/java/org/kohsuke/github/GitHubSanityCachedValueTest.java b/src/test/java/org/kohsuke/github/GitHubSanityCachedValueTest.java index 6facb6af21..a851ccec1e 100644 --- a/src/test/java/org/kohsuke/github/GitHubSanityCachedValueTest.java +++ b/src/test/java/org/kohsuke/github/GitHubSanityCachedValueTest.java @@ -21,6 +21,13 @@ private static void alignToStartOfSecond() { } } + /** + * Tests that the cache returns the same value without querying again when accessed multiple times within the same + * second. + * + * @throws Exception + * if the test fails + */ @Test public void cachesWithinSameSecond() throws Exception { alignToStartOfSecond(); @@ -41,6 +48,13 @@ public void cachesWithinSameSecond() throws Exception { assertThat(calls.get(), equalTo(1)); } + /** + * Tests that multiple concurrent callers only trigger a single refresh of the cached value, preventing redundant + * queries. + * + * @throws Exception + * if the test fails + */ @Test public void concurrentCallersOnlyRefreshOnce() throws Exception { alignToStartOfSecond(); @@ -82,6 +96,13 @@ public void concurrentCallersOnlyRefreshOnce() throws Exception { } } + /** + * Tests that the cache is refreshed after one second has elapsed, triggering a new query to retrieve the updated + * value. + * + * @throws Exception + * if the test fails + */ @Test public void refreshesAfterOneSecond() throws Exception { GitHubSanityCachedValue cachedValue = new GitHubSanityCachedValue<>(); From 34741e4731d43a37414b8544bd9cfe1a1e3d43c9 Mon Sep 17 00:00:00 2001 From: rball11 Date: Fri, 27 Feb 2026 15:01:30 -0700 Subject: [PATCH 5/5] feat Enabled read/write lock for GitHubSanityCachedValue [2172](https://github.com/hub4j/github-api/issues/2172) --- .../java/org/kohsuke/github/GitHubSanityCachedValueTest.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/test/java/org/kohsuke/github/GitHubSanityCachedValueTest.java b/src/test/java/org/kohsuke/github/GitHubSanityCachedValueTest.java index a851ccec1e..d47f14d4e3 100644 --- a/src/test/java/org/kohsuke/github/GitHubSanityCachedValueTest.java +++ b/src/test/java/org/kohsuke/github/GitHubSanityCachedValueTest.java @@ -13,6 +13,9 @@ import static org.hamcrest.CoreMatchers.notNullValue; import static org.hamcrest.MatcherAssert.assertThat; +/** + * The Class GitHubSanityCachedValueTest. + */ public class GitHubSanityCachedValueTest { private static void alignToStartOfSecond() {