Skip to content

Commit 79e0fc6

Browse files
authored
Merge pull request #2201 from ravikumar2026/feature/enableLock
feat Enabled read/write lock for GitHubSanityCachedValue
2 parents 9b923e8 + 7ef707d commit 79e0fc6

3 files changed

Lines changed: 207 additions & 47 deletions

File tree

src/main/java/org/kohsuke/github/GitHubClient.java

Lines changed: 23 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -953,32 +953,29 @@ String getLogin() {
953953
GHRateLimit getRateLimit(@Nonnull RateLimitTarget rateLimitTarget) throws IOException {
954954
// Even when explicitly asking for rate limit, restrict to sane query frequency
955955
// return cached value if available
956-
GHRateLimit output = sanityCachedRateLimit.get(
957-
(currentValue) -> currentValue == null || currentValue.getRecord(rateLimitTarget).isExpired(),
958-
() -> {
959-
GHRateLimit result;
960-
try {
961-
final GitHubRequest request = GitHubRequest.newBuilder()
962-
.rateLimit(RateLimitTarget.NONE)
963-
.withApiUrl(getApiUrl())
964-
.withUrlPath("/rate_limit")
965-
.build();
966-
result = this
967-
.sendRequest(request,
968-
(connectorResponse) -> GitHubResponse.parseBody(connectorResponse,
969-
JsonRateLimit.class))
970-
.body().resources;
971-
} catch (FileNotFoundException e) {
972-
// For some versions of GitHub Enterprise, the rate_limit endpoint returns a 404.
973-
LOGGER.log(FINE, "(%s) /rate_limit returned 404 Not Found.", sendRequestTraceId.get());
974-
975-
// However some newer versions of GHE include rate limit header information
976-
// If the header info is missing and the endpoint returns 404, fill the rate limit
977-
// with unknown
978-
result = GHRateLimit.fromRecord(GHRateLimit.UnknownLimitRecord.current(), rateLimitTarget);
979-
}
980-
return result;
981-
});
956+
GHRateLimit output = sanityCachedRateLimit.get(() -> {
957+
GHRateLimit result;
958+
try {
959+
final GitHubRequest request = GitHubRequest.newBuilder()
960+
.rateLimit(RateLimitTarget.NONE)
961+
.withApiUrl(getApiUrl())
962+
.withUrlPath("/rate_limit")
963+
.build();
964+
result = this
965+
.sendRequest(request,
966+
(connectorResponse) -> GitHubResponse.parseBody(connectorResponse, JsonRateLimit.class))
967+
.body().resources;
968+
} catch (FileNotFoundException e) {
969+
// For some versions of GitHub Enterprise, the rate_limit endpoint returns a 404.
970+
LOGGER.log(FINE, "(%s) /rate_limit returned 404 Not Found.", sendRequestTraceId.get());
971+
972+
// However some newer versions of GHE include rate limit header information
973+
// If the header info is missing and the endpoint returns 404, fill the rate limit
974+
// with unknown
975+
result = GHRateLimit.fromRecord(GHRateLimit.UnknownLimitRecord.current(), rateLimitTarget);
976+
}
977+
return result;
978+
});
982979
return updateRateLimit(output);
983980
}
984981

src/main/java/org/kohsuke/github/GitHubSanityCachedValue.java

Lines changed: 21 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
import org.kohsuke.github.function.SupplierThrows;
44

55
import java.time.Instant;
6-
import java.util.function.Function;
6+
import java.util.concurrent.locks.Lock;
7+
import java.util.concurrent.locks.ReentrantReadWriteLock;
78

89
/**
910
* GitHubSanityCachedValue limits queries for a particular value to once per second.
@@ -12,39 +13,38 @@ class GitHubSanityCachedValue<T> {
1213

1314
private long lastQueriedAtEpochSeconds = 0;
1415
private T lastResult = null;
15-
private final Object lock = new Object();
16+
// Allow concurrent readers while a refresh is not needed.
17+
private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
18+
private final Lock readLock = lock.readLock();
19+
private final Lock writeLock = lock.writeLock();
1620

1721
/**
1822
* Gets the value from the cache or calls the supplier if the cache is empty or out of date.
1923
*
20-
* @param isExpired
21-
* a supplier that returns true if the cached value is no longer valid.
2224
* @param query
2325
* a supplier the returns an updated value. Only called if the cache is empty or out of date.
2426
* @return the value from the cache or the value returned from the supplier.
2527
* @throws E
2628
* the exception thrown by the supplier if it fails.
2729
*/
28-
<E extends Throwable> T get(Function<T, Boolean> isExpired, SupplierThrows<T, E> query) throws E {
29-
synchronized (lock) {
30-
if (Instant.now().getEpochSecond() > lastQueriedAtEpochSeconds || isExpired.apply(lastResult)) {
30+
<E extends Throwable> T get(SupplierThrows<T, E> query) throws E {
31+
readLock.lock();
32+
try {
33+
if (Instant.now().getEpochSecond() <= lastQueriedAtEpochSeconds) {
34+
return lastResult;
35+
}
36+
} finally {
37+
readLock.unlock();
38+
}
39+
writeLock.lock();
40+
try {
41+
if (Instant.now().getEpochSecond() > lastQueriedAtEpochSeconds) {
3142
lastResult = query.get();
3243
lastQueriedAtEpochSeconds = Instant.now().getEpochSecond();
3344
}
45+
return lastResult;
46+
} finally {
47+
writeLock.unlock();
3448
}
35-
return lastResult;
36-
}
37-
38-
/**
39-
* Gets the value from the cache or calls the supplier if the cache is empty or out of date.
40-
*
41-
* @param query
42-
* a supplier the returns an updated value. Only called if the cache is empty or out of date.
43-
* @return the value from the cache or the value returned from the supplier.
44-
* @throws E
45-
* the exception thrown by the supplier if it fails.
46-
*/
47-
<E extends Throwable> T get(SupplierThrows<T, E> query) throws E {
48-
return get((value) -> Boolean.FALSE, query);
4949
}
5050
}
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
package org.kohsuke.github;
2+
3+
import org.junit.Test;
4+
5+
import java.time.Instant;
6+
import java.util.ArrayList;
7+
import java.util.Collections;
8+
import java.util.List;
9+
import java.util.concurrent.CountDownLatch;
10+
import java.util.concurrent.atomic.AtomicInteger;
11+
12+
import static org.hamcrest.CoreMatchers.equalTo;
13+
import static org.hamcrest.CoreMatchers.notNullValue;
14+
import static org.hamcrest.MatcherAssert.assertThat;
15+
16+
/**
17+
* The Class GitHubSanityCachedValueTest.
18+
*/
19+
public class GitHubSanityCachedValueTest {
20+
21+
private static void alignToStartOfSecond() {
22+
while (Instant.now().getNano() > 100_000_000) {
23+
Thread.yield();
24+
}
25+
}
26+
27+
/**
28+
* Tests that the cache returns the same value without querying again when accessed multiple times within the same
29+
* second.
30+
*
31+
* @throws Exception
32+
* if the test fails
33+
*/
34+
@Test
35+
public void cachesWithinSameSecond() throws Exception {
36+
alignToStartOfSecond();
37+
GitHubSanityCachedValue<String> cachedValue = new GitHubSanityCachedValue<>();
38+
AtomicInteger calls = new AtomicInteger();
39+
40+
String first = cachedValue.get(() -> {
41+
calls.incrementAndGet();
42+
return "value";
43+
});
44+
String second = cachedValue.get(() -> {
45+
calls.incrementAndGet();
46+
return "value";
47+
});
48+
49+
assertThat(first, equalTo("value"));
50+
assertThat(second, equalTo("value"));
51+
assertThat(calls.get(), equalTo(1));
52+
}
53+
54+
/**
55+
* Tests that multiple concurrent callers only trigger a single refresh of the cached value, preventing redundant
56+
* queries.
57+
*
58+
* @throws Exception
59+
* if the test fails
60+
*/
61+
@Test
62+
public void concurrentCallersOnlyRefreshOnce() throws Exception {
63+
alignToStartOfSecond();
64+
GitHubSanityCachedValue<String> cachedValue = new GitHubSanityCachedValue<>();
65+
AtomicInteger calls = new AtomicInteger();
66+
List<String> results = Collections.synchronizedList(new ArrayList<>());
67+
CountDownLatch ready = new CountDownLatch(5);
68+
CountDownLatch start = new CountDownLatch(1);
69+
CountDownLatch finished = new CountDownLatch(5);
70+
71+
for (int i = 0; i < 5; i++) {
72+
Thread thread = new Thread(() -> {
73+
try {
74+
ready.countDown();
75+
start.await();
76+
String value = cachedValue.get(() -> {
77+
calls.incrementAndGet();
78+
return "value";
79+
});
80+
results.add(value);
81+
} catch (Exception ignored) {
82+
results.add(null);
83+
} finally {
84+
finished.countDown();
85+
}
86+
});
87+
thread.start();
88+
}
89+
90+
ready.await();
91+
start.countDown();
92+
finished.await();
93+
94+
assertThat(calls.get(), equalTo(1));
95+
assertThat(results.size(), equalTo(5));
96+
for (String result : results) {
97+
assertThat(result, notNullValue());
98+
assertThat(result, equalTo("value"));
99+
}
100+
}
101+
102+
/**
103+
* Tests that a result which is already expired on arrival — for example, the {@code GHRateLimit.UnknownLimitRecord}
104+
* returned when a GitHub Enterprise {@code /rate_limit} endpoint responds with 404 — is still held for one second.
105+
* Without the time-based TTL, re-checking expiry immediately after a refresh would cause every subsequent call to
106+
* re-query, creating a query storm.
107+
*
108+
* @throws Exception
109+
* if the test fails
110+
*/
111+
@Test
112+
public void doesNotReQueryWhenResultIsAlreadyExpiredOnArrival() throws Exception {
113+
alignToStartOfSecond();
114+
GitHubSanityCachedValue<String> cachedValue = new GitHubSanityCachedValue<>();
115+
AtomicInteger calls = new AtomicInteger();
116+
117+
// Supplier always returns a value that an isExpired() check would immediately reject,
118+
// e.g. GHRateLimit.UnknownLimitRecord when GitHub Enterprise returns 404 for /rate_limit.
119+
cachedValue.get(() -> {
120+
calls.incrementAndGet();
121+
return "expired-on-arrival";
122+
});
123+
cachedValue.get(() -> {
124+
calls.incrementAndGet();
125+
return "expired-on-arrival";
126+
});
127+
cachedValue.get(() -> {
128+
calls.incrementAndGet();
129+
return "expired-on-arrival";
130+
});
131+
132+
assertThat(calls.get(), equalTo(1));
133+
}
134+
135+
/**
136+
* Tests that the cache is refreshed after one second has elapsed, triggering a new query to retrieve the updated
137+
* value.
138+
*
139+
* @throws Exception
140+
* if the test fails
141+
*/
142+
@Test
143+
public void refreshesAfterOneSecond() throws Exception {
144+
GitHubSanityCachedValue<String> cachedValue = new GitHubSanityCachedValue<>();
145+
AtomicInteger calls = new AtomicInteger();
146+
147+
String first = cachedValue.get(() -> {
148+
calls.incrementAndGet();
149+
return "value";
150+
});
151+
152+
Thread.sleep(1100);
153+
154+
String second = cachedValue.get(() -> {
155+
calls.incrementAndGet();
156+
return "value";
157+
});
158+
159+
assertThat(first, equalTo("value"));
160+
assertThat(second, equalTo("value"));
161+
assertThat(calls.get(), equalTo(2));
162+
}
163+
}

0 commit comments

Comments
 (0)