Skip to content

Commit 0f53aca

Browse files
runningcodeclaude
andcommitted
perf: Schedule rate-limit notifications on shared executor (JAVA-653)
RateLimiter created a java.util.Timer whose thread stayed alive forever once the SDK got rate limited. Schedule the "rate limit lifted" observer notification on the shared timer executor instead, whose single worker thread is reused across all timeouts and self-terminates when idle. Pending notifications are cancelled on close(). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 4670d89 commit 0f53aca

2 files changed

Lines changed: 47 additions & 35 deletions

File tree

sentry/src/main/java/io/sentry/transport/RateLimiter.java

Lines changed: 33 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,13 @@
2323
import java.util.Arrays;
2424
import java.util.Collections;
2525
import java.util.Date;
26+
import java.util.Iterator;
2627
import java.util.List;
2728
import java.util.Map;
28-
import java.util.Timer;
29-
import java.util.TimerTask;
3029
import java.util.concurrent.ConcurrentHashMap;
3130
import java.util.concurrent.CopyOnWriteArrayList;
31+
import java.util.concurrent.Future;
32+
import java.util.concurrent.RejectedExecutionException;
3233
import org.jetbrains.annotations.NotNull;
3334
import org.jetbrains.annotations.Nullable;
3435

@@ -42,8 +43,9 @@ public final class RateLimiter implements Closeable {
4243
private final @NotNull Map<DataCategory, @NotNull Date> sentryRetryAfterLimit =
4344
new ConcurrentHashMap<>();
4445
private final @NotNull List<IRateLimitObserver> rateLimitObservers = new CopyOnWriteArrayList<>();
45-
private @Nullable Timer timer = null;
46-
private final @NotNull AutoClosableReentrantLock timerLock = new AutoClosableReentrantLock();
46+
private final @NotNull List<Future<?>> notifyObserversFutures = new ArrayList<>();
47+
private final @NotNull AutoClosableReentrantLock notifyFuturesLock =
48+
new AutoClosableReentrantLock();
4749

4850
public RateLimiter(
4951
final @NotNull ICurrentDateProvider currentDateProvider,
@@ -278,11 +280,11 @@ public void updateRetryAfterLimits(
278280
continue;
279281
}
280282

281-
applyRetryAfterOnlyIfLonger(dataCategory, date);
283+
applyRetryAfterOnlyIfLonger(dataCategory, date, retryAfterMillis);
282284
}
283285
} else {
284286
// if categories are empty, we should apply to "all" categories.
285-
applyRetryAfterOnlyIfLonger(DataCategory.All, date);
287+
applyRetryAfterOnlyIfLonger(DataCategory.All, date, retryAfterMillis);
286288
}
287289
}
288290
}
@@ -291,7 +293,7 @@ public void updateRetryAfterLimits(
291293
final long retryAfterMillis = parseRetryAfterOrDefault(retryAfterHeader);
292294
// we dont care if Date is UTC as we just add the relative seconds
293295
final Date date = new Date(currentDateProvider.getCurrentTimeMillis() + retryAfterMillis);
294-
applyRetryAfterOnlyIfLonger(DataCategory.All, date);
296+
applyRetryAfterOnlyIfLonger(DataCategory.All, date, retryAfterMillis);
295297
}
296298
}
297299

@@ -300,10 +302,11 @@ public void updateRetryAfterLimits(
300302
*
301303
* @param dataCategory the DataCategory
302304
* @param date the Date to be applied
305+
* @param delayMillis the millis until the rate limit is lifted
303306
*/
304307
@SuppressWarnings({"JdkObsolete", "JavaUtilDate"})
305308
private void applyRetryAfterOnlyIfLonger(
306-
final @NotNull DataCategory dataCategory, final @NotNull Date date) {
309+
final @NotNull DataCategory dataCategory, final @NotNull Date date, final long delayMillis) {
307310
final Date oldDate = sentryRetryAfterLimit.get(dataCategory);
308311

309312
// only overwrite its previous date if the limit is even longer
@@ -312,19 +315,25 @@ private void applyRetryAfterOnlyIfLonger(
312315

313316
notifyRateLimitObservers();
314317

315-
try (final @NotNull ISentryLifecycleToken ignored = timerLock.acquire()) {
316-
if (timer == null) {
317-
timer = new Timer(true);
318+
// notify observers again once the rate limit is lifted, using the shared timer executor
319+
// instead of a dedicated Timer thread
320+
try (final @NotNull ISentryLifecycleToken ignored = notifyFuturesLock.acquire()) {
321+
final @NotNull Iterator<Future<?>> iterator = notifyObserversFutures.iterator();
322+
while (iterator.hasNext()) {
323+
if (iterator.next().isDone()) {
324+
iterator.remove();
325+
}
326+
}
327+
try {
328+
notifyObserversFutures.add(
329+
options
330+
.getTimerExecutorService()
331+
.schedule(() -> notifyRateLimitObservers(), delayMillis));
332+
} catch (RejectedExecutionException e) {
333+
options
334+
.getLogger()
335+
.log(SentryLevel.WARNING, "Failed to schedule rate limit lifted notification.", e);
318336
}
319-
320-
timer.schedule(
321-
new TimerTask() {
322-
@Override
323-
public void run() {
324-
notifyRateLimitObservers();
325-
}
326-
},
327-
date);
328337
}
329338
}
330339
}
@@ -364,11 +373,11 @@ public void removeRateLimitObserver(@NotNull final IRateLimitObserver observer)
364373

365374
@Override
366375
public void close() throws IOException {
367-
try (final @NotNull ISentryLifecycleToken ignored = timerLock.acquire()) {
368-
if (timer != null) {
369-
timer.cancel();
370-
timer = null;
376+
try (final @NotNull ISentryLifecycleToken ignored = notifyFuturesLock.acquire()) {
377+
for (Future<?> future : notifyObserversFutures) {
378+
future.cancel(false);
371379
}
380+
notifyObserversFutures.clear();
372381
}
373382
rateLimitObservers.clear();
374383
}

sentry/src/test/java/io/sentry/transport/RateLimiterTest.kt

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import io.sentry.SentryEnvelope
1818
import io.sentry.SentryEnvelopeHeader
1919
import io.sentry.SentryEnvelopeItem
2020
import io.sentry.SentryEvent
21+
import io.sentry.SentryExecutorService
2122
import io.sentry.SentryLogEvent
2223
import io.sentry.SentryLogEvents
2324
import io.sentry.SentryLogLevel
@@ -37,11 +38,10 @@ import io.sentry.protocol.SentryId
3738
import io.sentry.protocol.SentryTransaction
3839
import io.sentry.protocol.User
3940
import io.sentry.test.getProperty
40-
import io.sentry.test.injectForField
4141
import io.sentry.util.HintUtils
4242
import java.io.File
43-
import java.util.Timer
4443
import java.util.UUID
44+
import java.util.concurrent.Future
4545
import java.util.concurrent.atomic.AtomicBoolean
4646
import kotlin.test.Test
4747
import kotlin.test.assertEquals
@@ -66,6 +66,8 @@ class RateLimiterTest {
6666

6767
fun getSUT(): RateLimiter {
6868
val options = SentryOptions().apply { setLogger(NoOpLogger.getInstance()) }
69+
// a real executor so scheduled rate-limit-lifted notifications actually run
70+
options.setTimerExecutorService(SentryExecutorService(options))
6971

7072
SentryOptionsManipulator.setClientReportRecorder(options, clientReportRecorder)
7173

@@ -654,7 +656,7 @@ class RateLimiterTest {
654656
}
655657

656658
@Test
657-
fun `apply rate limits schedules a timer to notify observers of lifted limits`() {
659+
fun `apply rate limits schedules a task to notify observers of lifted limits`() {
658660
val rateLimiter = fixture.getSUT()
659661
whenever(fixture.currentDateProvider.currentTimeMillis).thenReturn(0, 1, 2001)
660662

@@ -667,18 +669,19 @@ class RateLimiterTest {
667669
}
668670

669671
@Test
670-
fun `close cancels the timer`() {
672+
fun `close cancels pending notify tasks`() {
671673
val rateLimiter = fixture.getSUT()
672-
val timer = mock<Timer>()
673-
rateLimiter.injectForField("timer", timer)
674+
rateLimiter.updateRetryAfterLimits("60:replay:key", null, 1)
675+
676+
val futures = rateLimiter.getProperty<List<Future<*>>>("notifyObserversFutures")
677+
assertEquals(1, futures.size)
678+
val future = futures.first()
674679

675680
// When the rate limiter is closed
676681
rateLimiter.close()
677682

678-
// Then the timer is cancelled
679-
verify(timer).cancel()
680-
681-
// And is removed by the rateLimiter
682-
assertNull(rateLimiter.getProperty("timer"))
683+
// Then the pending notify task is cancelled and dropped
684+
assertTrue(future.isCancelled)
685+
assertTrue(rateLimiter.getProperty<List<Future<*>>>("notifyObserversFutures").isEmpty())
683686
}
684687
}

0 commit comments

Comments
 (0)