Skip to content

Commit bcd3e76

Browse files
runningcodeclaude
andauthored
perf: Avoid per-transaction Timer thread in SentryTracer (JAVA-596) (#5670)
* perf: Avoid per-transaction Timer thread in SentryTracer (JAVA-570) Transactions with an idle or deadline timeout each created a java.util.Timer, which spawns a thread synchronously on the calling thread (often the main thread on Android). At scale (screen loads, HTTP spans) this was the dominant source of SDK thread churn. Schedule the idle/deadline timeouts on a dedicated, shared ISentryExecutorService held by SentryOptions instead, so no thread is created per transaction. It is kept separate from the main executor so timeout callbacks (which finish transactions) don't contend with cached event sending, and it is not prewarmed: its single worker thread is spawned lazily on the first scheduled timeout and reused thereafter. The dedicated executor uses removeOnCancelPolicy so cancelled timeouts (idle timers are rescheduled per child span) don't accumulate in its queue. On finish only the scheduled futures are cancelled; the executor is closed with the SDK. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * changelog * fix(core): Keep timer executor alive across SDK restart (JAVA-570) The shared timer executor introduced for transaction idle/deadline timeouts was shut down on every Scopes.close(), including SDK restart. This cancelled the pending idle timeout of any transaction started before the restart (e.g. an in-flight activity transaction), so it never auto-finished and its envelope was never sent. Only close the timer executor on a full close, not on restart, matching the pre-existing per-transaction Timer behaviour. Enable core-thread timeout on the timer executor so the instance abandoned by a restart self-terminates once idle instead of leaking a thread. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(core): Look up shared timer executor on demand (JAVA-570) SentryTracer cached the shared timer executor in a field for the lifetime of the transaction. Replace that field with a boolean flag tracking whether timeouts may still be scheduled, and fetch the executor from the options each time one is scheduled. This ensures the tracer always uses the executor currently held by the options (e.g. the fresh one installed after an SDK restart) rather than a stale reference. Also make the timer executor's keep-alive duration a constructor argument backed by the named TIMER_KEEP_ALIVE_SECONDS constant, and raise it from 10s to 30s so the shared worker thread is less likely to be torn down and respawned between transactions under normal use. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent dd1eb0c commit bcd3e76

11 files changed

Lines changed: 182 additions & 62 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@
3535
- Prevent logs and metrics from remaining queued after a flush scheduling race ([#5756](https://github.com/getsentry/sentry-java/pull/5756))
3636
- Fix main thread identification for tombstone (native crash) events ([#5742](https://github.com/getsentry/sentry-java/pull/5742))
3737

38+
### Performance
39+
40+
- Schedule transaction idle/deadline timeouts on a shared, dedicated executor instead of spawning a `Timer` thread per transaction ([#5670](https://github.com/getsentry/sentry-java/pull/5670))
41+
3842
### Dependencies
3943

4044
- Bump OpenTelemetry to support Spring Boot 4.1 ([#5573](https://github.com/getsentry/sentry-java/pull/5573))

sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import io.sentry.Scopes
2323
import io.sentry.Sentry
2424
import io.sentry.SentryDate
2525
import io.sentry.SentryDateProvider
26+
import io.sentry.SentryExecutorService
2627
import io.sentry.SentryNanotimeDate
2728
import io.sentry.SentryTraceHeader
2829
import io.sentry.SentryTracer
@@ -919,6 +920,8 @@ class ActivityLifecycleIntegrationTest {
919920
it.idleTimeout = 100
920921
}
921922
)
923+
// the transaction idle timeout is scheduled on the dedicated timer executor
924+
fixture.options.timerExecutorService = SentryExecutorService()
922925
sut.register(fixture.scopes, fixture.options)
923926
sut.onActivityCreated(activity, fixture.bundle)
924927

sentry/api/sentry.api

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3719,6 +3719,7 @@ public class io/sentry/SentryOptions {
37193719
public fun getSslSocketFactory ()Ljavax/net/ssl/SSLSocketFactory;
37203720
public fun getTags ()Ljava/util/Map;
37213721
public fun getThreadChecker ()Lio/sentry/util/thread/IThreadChecker;
3722+
public fun getTimerExecutorService ()Lio/sentry/ISentryExecutorService;
37223723
public fun getTracePropagationTargets ()Ljava/util/List;
37233724
public fun getTracesSampleRate ()Ljava/lang/Double;
37243725
public fun getTracesSampler ()Lio/sentry/SentryOptions$TracesSamplerCallback;
@@ -3884,6 +3885,7 @@ public class io/sentry/SentryOptions {
38843885
public fun setStrictTraceContinuation (Z)V
38853886
public fun setTag (Ljava/lang/String;Ljava/lang/String;)V
38863887
public fun setThreadChecker (Lio/sentry/util/thread/IThreadChecker;)V
3888+
public fun setTimerExecutorService (Lio/sentry/ISentryExecutorService;)V
38873889
public fun setTraceOptionsRequests (Z)V
38883890
public fun setTracePropagationTargets (Ljava/util/List;)V
38893891
public fun setTraceSampling (Z)V

sentry/src/main/java/io/sentry/Scopes.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -467,6 +467,12 @@ public void close(final boolean isRestarting) {
467467
getOptions().getContinuousProfiler().close(true);
468468
getOptions().getCompositePerformanceCollector().close();
469469
getOptions().getConnectionStatusProvider().close();
470+
// On restart we intentionally leave the timer executor running so that pending idle/
471+
// deadline timeouts of transactions started before the restart still fire and finish
472+
// those transactions. It self-terminates once idle (allowCoreThreadTimeOut).
473+
if (!isRestarting) {
474+
getOptions().getTimerExecutorService().close(getOptions().getShutdownTimeoutMillis());
475+
}
470476
final @NotNull ISentryExecutorService executorService = getOptions().getExecutorService();
471477
if (isRestarting) {
472478
try {

sentry/src/main/java/io/sentry/Sentry.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,12 @@ private static void init(final @NotNull SentryOptions options, final boolean glo
353353
options.setExecutorService(new SentryExecutorService(options));
354354
}
355355

356+
if (options.getTimerExecutorService().isClosed()) {
357+
options.setTimerExecutorService(
358+
new SentryExecutorService(
359+
options, true, SentryExecutorService.TIMER_KEEP_ALIVE_SECONDS, TimeUnit.SECONDS));
360+
}
361+
356362
// load lazy fields of the options in a separate thread
357363
try {
358364
options.getExecutorService().submit(() -> options.loadLazyFields());

sentry/src/main/java/io/sentry/SentryExecutorService.java

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,12 @@ public final class SentryExecutorService implements ISentryExecutorService {
2222
*/
2323
private static final int MAX_QUEUE_SIZE = 271;
2424

25+
/**
26+
* How long the timer executor's worker thread stays alive while idle before self-terminating, so
27+
* an instance abandoned on SDK restart doesn't leak a live thread once its queue drains.
28+
*/
29+
static final long TIMER_KEEP_ALIVE_SECONDS = 30;
30+
2531
private final @NotNull ScheduledThreadPoolExecutor executorService;
2632
private final @NotNull AutoClosableReentrantLock lock = new AutoClosableReentrantLock();
2733

@@ -39,6 +45,19 @@ public SentryExecutorService(final @Nullable SentryOptions options) {
3945
this(new ScheduledThreadPoolExecutor(1, new SentryExecutorServiceThreadFactory()), options);
4046
}
4147

48+
SentryExecutorService(
49+
final @Nullable SentryOptions options,
50+
final boolean removeOnCancelPolicy,
51+
final long keepAliveTime,
52+
final @NotNull TimeUnit keepAliveTimeUnit) {
53+
this(options);
54+
// removes cancelled tasks from the work queue immediately instead of leaving them until their
55+
// scheduled time; useful for executors that frequently reschedule (e.g. transaction timeouts)
56+
executorService.setRemoveOnCancelPolicy(removeOnCancelPolicy);
57+
executorService.setKeepAliveTime(keepAliveTime, keepAliveTimeUnit);
58+
executorService.allowCoreThreadTimeOut(true);
59+
}
60+
4261
public SentryExecutorService() {
4362
this(new ScheduledThreadPoolExecutor(1, new SentryExecutorServiceThreadFactory()), null);
4463
}

sentry/src/main/java/io/sentry/SentryOptions.java

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
import java.util.concurrent.ConcurrentHashMap;
4545
import java.util.concurrent.CopyOnWriteArrayList;
4646
import java.util.concurrent.CopyOnWriteArraySet;
47+
import java.util.concurrent.TimeUnit;
4748
import java.util.concurrent.atomic.AtomicBoolean;
4849
import javax.net.ssl.SSLSocketFactory;
4950
import org.jetbrains.annotations.ApiStatus;
@@ -317,6 +318,14 @@ public class SentryOptions {
317318
/** Sentry Executor Service that sends cached events and envelopes on App. start. */
318319
private @NotNull ISentryExecutorService executorService = NoOpSentryExecutorService.getInstance();
319320

321+
/**
322+
* Dedicated executor for scheduling transaction idle/deadline timeouts. Kept separate from {@link
323+
* #executorService} so timeout callbacks (which finish transactions) don't contend with cached
324+
* event sending.
325+
*/
326+
private @NotNull ISentryExecutorService timerExecutorService =
327+
NoOpSentryExecutorService.getInstance();
328+
320329
/**
321330
* Whether SpotlightIntegration has already been loaded via reflection. This prevents re-adding it
322331
* if the user removed it in their configuration callback and activate() is called again.
@@ -683,6 +692,15 @@ public void activate() {
683692
executorService = new SentryExecutorService(this);
684693
}
685694

695+
if (timerExecutorService instanceof NoOpSentryExecutorService) {
696+
// Not prewarmed: its single worker thread is spawned lazily on the first scheduled timeout
697+
// and then reused across all transactions. removeOnCancelPolicy keeps the work queue from
698+
// accumulating cancelled timeouts (idle timers are cancelled and rescheduled per child span).
699+
timerExecutorService =
700+
new SentryExecutorService(
701+
this, true, SentryExecutorService.TIMER_KEEP_ALIVE_SECONDS, TimeUnit.SECONDS);
702+
}
703+
686704
// SpotlightIntegration is loaded via reflection to allow the sentry-spotlight module
687705
// to be excluded from release builds, preventing insecure HTTP URLs from appearing in APKs.
688706
// Only attempt once to avoid re-adding after user removal in their configuration callback.
@@ -1570,6 +1588,30 @@ public void setExecutorService(final @NotNull ISentryExecutorService executorSer
15701588
}
15711589
}
15721590

1591+
/**
1592+
* Returns the dedicated executor used to schedule transaction idle/deadline timeouts.
1593+
*
1594+
* @return the timer executor service
1595+
*/
1596+
@ApiStatus.Internal
1597+
@NotNull
1598+
public ISentryExecutorService getTimerExecutorService() {
1599+
return timerExecutorService;
1600+
}
1601+
1602+
/**
1603+
* Sets the dedicated executor used to schedule transaction idle/deadline timeouts.
1604+
*
1605+
* @param timerExecutorService the timer executor service
1606+
*/
1607+
@ApiStatus.Internal
1608+
@TestOnly
1609+
public void setTimerExecutorService(final @NotNull ISentryExecutorService timerExecutorService) {
1610+
if (timerExecutorService != null) {
1611+
this.timerExecutorService = timerExecutorService;
1612+
}
1613+
}
1614+
15731615
/**
15741616
* Returns the connection timeout in milliseconds.
15751617
*

sentry/src/main/java/io/sentry/SentryTracer.java

Lines changed: 34 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,8 @@
1212
import java.util.List;
1313
import java.util.ListIterator;
1414
import java.util.Map;
15-
import java.util.Timer;
16-
import java.util.TimerTask;
1715
import java.util.concurrent.CopyOnWriteArrayList;
16+
import java.util.concurrent.Future;
1817
import java.util.concurrent.atomic.AtomicBoolean;
1918
import java.util.concurrent.atomic.AtomicReference;
2019
import org.jetbrains.annotations.ApiStatus;
@@ -37,10 +36,12 @@ public final class SentryTracer implements ITransaction {
3736
*/
3837
private @NotNull FinishStatus finishStatus = FinishStatus.NOT_FINISHED;
3938

40-
private volatile @Nullable TimerTask idleTimeoutTask;
41-
private volatile @Nullable TimerTask deadlineTimeoutTask;
39+
private volatile @Nullable Future<?> idleTimeoutFuture;
40+
private volatile @Nullable Future<?> deadlineTimeoutFuture;
4241

43-
private volatile @Nullable Timer timer = null;
42+
// Whether timeout tasks may still be scheduled. Set to false once the tracer is finished. The
43+
// executor itself is owned by the options (shared SDK-wide) and obtained from there when needed.
44+
private volatile boolean timersEnabled = false;
4445
private final @NotNull AutoClosableReentrantLock timerLock = new AutoClosableReentrantLock();
4546
private final @NotNull AutoClosableReentrantLock tracerLock = new AutoClosableReentrantLock();
4647

@@ -99,7 +100,7 @@ public SentryTracer(
99100

100101
if (transactionOptions.getIdleTimeout() != null
101102
|| transactionOptions.getDeadlineTimeout() != null) {
102-
timer = new Timer(true);
103+
timersEnabled = true;
103104

104105
scheduleDeadlineTimeout();
105106
scheduleFinish();
@@ -109,22 +110,19 @@ public SentryTracer(
109110
@Override
110111
public void scheduleFinish() {
111112
try (final @NotNull ISentryLifecycleToken ignored = timerLock.acquire()) {
112-
if (timer != null) {
113+
if (timersEnabled) {
113114
final @Nullable Long idleTimeout = transactionOptions.getIdleTimeout();
114115

115116
if (idleTimeout != null) {
116117
cancelIdleTimer();
117118
isIdleFinishTimerRunning.set(true);
118-
idleTimeoutTask =
119-
new TimerTask() {
120-
@Override
121-
public void run() {
122-
onIdleTimeoutReached();
123-
}
124-
};
125119

126120
try {
127-
timer.schedule(idleTimeoutTask, idleTimeout);
121+
idleTimeoutFuture =
122+
scopes
123+
.getOptions()
124+
.getTimerExecutorService()
125+
.schedule(this::onIdleTimeoutReached, idleTimeout);
128126
} catch (Throwable e) {
129127
scopes
130128
.getOptions()
@@ -265,13 +263,12 @@ public void finish(
265263
});
266264
final SentryTransaction transaction = new SentryTransaction(this);
267265

268-
if (timer != null) {
266+
if (timersEnabled) {
269267
try (final @NotNull ISentryLifecycleToken ignored = timerLock.acquire()) {
270-
if (timer != null) {
268+
if (timersEnabled) {
271269
cancelIdleTimer();
272270
cancelDeadlineTimer();
273-
timer.cancel();
274-
timer = null;
271+
timersEnabled = false;
275272
}
276273
}
277274
}
@@ -295,10 +292,10 @@ public void finish(
295292

296293
private void cancelIdleTimer() {
297294
try (final @NotNull ISentryLifecycleToken ignored = timerLock.acquire()) {
298-
if (idleTimeoutTask != null) {
299-
idleTimeoutTask.cancel();
295+
if (idleTimeoutFuture != null) {
296+
idleTimeoutFuture.cancel(false);
300297
isIdleFinishTimerRunning.set(false);
301-
idleTimeoutTask = null;
298+
idleTimeoutFuture = null;
302299
}
303300
}
304301
}
@@ -307,18 +304,15 @@ private void scheduleDeadlineTimeout() {
307304
final @Nullable Long deadlineTimeOut = transactionOptions.getDeadlineTimeout();
308305
if (deadlineTimeOut != null) {
309306
try (final @NotNull ISentryLifecycleToken ignored = timerLock.acquire()) {
310-
if (timer != null) {
307+
if (timersEnabled) {
311308
cancelDeadlineTimer();
312309
isDeadlineTimerRunning.set(true);
313-
deadlineTimeoutTask =
314-
new TimerTask() {
315-
@Override
316-
public void run() {
317-
onDeadlineTimeoutReached();
318-
}
319-
};
320310
try {
321-
timer.schedule(deadlineTimeoutTask, deadlineTimeOut);
311+
deadlineTimeoutFuture =
312+
scopes
313+
.getOptions()
314+
.getTimerExecutorService()
315+
.schedule(this::onDeadlineTimeoutReached, deadlineTimeOut);
322316
} catch (Throwable e) {
323317
scopes
324318
.getOptions()
@@ -335,10 +329,10 @@ public void run() {
335329

336330
private void cancelDeadlineTimer() {
337331
try (final @NotNull ISentryLifecycleToken ignored = timerLock.acquire()) {
338-
if (deadlineTimeoutTask != null) {
339-
deadlineTimeoutTask.cancel();
332+
if (deadlineTimeoutFuture != null) {
333+
deadlineTimeoutFuture.cancel(false);
340334
isDeadlineTimerRunning.set(false);
341-
deadlineTimeoutTask = null;
335+
deadlineTimeoutFuture = null;
342336
}
343337
}
344338
}
@@ -973,20 +967,19 @@ Span getRoot() {
973967

974968
@TestOnly
975969
@Nullable
976-
TimerTask getIdleTimeoutTask() {
977-
return idleTimeoutTask;
970+
Future<?> getIdleTimeoutFuture() {
971+
return idleTimeoutFuture;
978972
}
979973

980974
@TestOnly
981975
@Nullable
982-
TimerTask getDeadlineTimeoutTask() {
983-
return deadlineTimeoutTask;
976+
Future<?> getDeadlineTimeoutFuture() {
977+
return deadlineTimeoutFuture;
984978
}
985979

986980
@TestOnly
987-
@Nullable
988-
Timer getTimer() {
989-
return timer;
981+
boolean areTimersEnabled() {
982+
return timersEnabled;
990983
}
991984

992985
@TestOnly

sentry/src/test/java/io/sentry/ScopesTest.kt

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1946,6 +1946,32 @@ class ScopesTest {
19461946
verify(executor).close(any())
19471947
}
19481948

1949+
@Test
1950+
fun `Scopes with isRestarting true should not close the timer executor`() {
1951+
val timerExecutor = mock<ISentryExecutorService>()
1952+
val options =
1953+
SentryOptions().apply {
1954+
dsn = "https://key@sentry.io/proj"
1955+
setTimerExecutorService(timerExecutor)
1956+
}
1957+
val sut = createScopes(options)
1958+
sut.close(true)
1959+
verify(timerExecutor, never()).close(any())
1960+
}
1961+
1962+
@Test
1963+
fun `Scopes with isRestarting false should close the timer executor`() {
1964+
val timerExecutor = mock<ISentryExecutorService>()
1965+
val options =
1966+
SentryOptions().apply {
1967+
dsn = "https://key@sentry.io/proj"
1968+
setTimerExecutorService(timerExecutor)
1969+
}
1970+
val sut = createScopes(options)
1971+
sut.close(false)
1972+
verify(timerExecutor).close(any())
1973+
}
1974+
19491975
@Test
19501976
fun `Scopes close should clear the scope`() {
19511977
val options = SentryOptions().apply { dsn = "https://key@sentry.io/proj" }

sentry/src/test/java/io/sentry/SentryExecutorServiceTest.kt

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package io.sentry
22

3+
import io.sentry.test.getProperty
34
import java.util.concurrent.BlockingQueue
45
import java.util.concurrent.Callable
56
import java.util.concurrent.CancellationException
@@ -93,6 +94,22 @@ class SentryExecutorServiceTest {
9394
sentryExecutor.close(15000)
9495
}
9596

97+
@Test
98+
fun `SentryExecutorService enables removeOnCancelPolicy when requested`() {
99+
val sentryExecutor = SentryExecutorService(null, true, 30, TimeUnit.SECONDS)
100+
val executor = sentryExecutor.getProperty<ScheduledThreadPoolExecutor>("executorService")
101+
assertTrue(executor.removeOnCancelPolicy)
102+
sentryExecutor.close(15000)
103+
}
104+
105+
@Test
106+
fun `SentryExecutorService does not enable removeOnCancelPolicy by default`() {
107+
val sentryExecutor = SentryExecutorService(null)
108+
val executor = sentryExecutor.getProperty<ScheduledThreadPoolExecutor>("executorService")
109+
assertFalse(executor.removeOnCancelPolicy)
110+
sentryExecutor.close(15000)
111+
}
112+
96113
@Test
97114
fun `SentryExecutorService isClosed returns true if executor is shutdown`() {
98115
val executor = mock<ScheduledThreadPoolExecutor>()

0 commit comments

Comments
 (0)