Pada aplikasi Spring Boot, anotasi @Scheduled sering digunakan untuk menjalankan cron job berkala seperti rekonsiliasi data, sinkronisasi cache, atau pengiriman laporan. Masalah umum di produksi adalah ketika seluruh scheduled task berhenti dieksekusi secara tiba-tiba tanpa meninggalkan log error atau exception apapun.

Gejala ini adalah indikasi klasik task starvation. Masalah ini berakar pada implementasi default ThreadPoolTaskScheduler di Spring Boot yang hanya menyediakan satu worker thread.

Root Cause: Single Worker Thread Default

Secara default, Spring Boot mengonfigurasi scheduler dengan pool size bernilai 1. Artinya, seluruh metode yang dianotasi @Scheduled di dalam aplikasi berbagi satu thread tunggal (biasanya bernama scheduling-1).

Jika salah satu task mengalami blocking I/O—seperti request HTTP ke third-party API yang tidak memiliki socket timeout, query database lambat tanpa lock limit, atau network call yang hang—thread tunggal tersebut akan tertahan indefinitely. Akibatnya:

  • Task yang sedang berjalan tidak kunjung selesai.
  • Tidak ada unhandled exception yang terlempar, sehingga log aplikasi tetap bersih.
  • Task-task terjadwal lainnya mengantre di queue memori dan tidak akan pernah dieksekusi.

Investigasi Menggunakan Thread Dump (jstack)

Pemeriksaan log standar tidak akan membuahkan hasil jika thread tertahan di layer socket atau kernel. Anda memerlukan thread dump JVM untuk memvalidasi status thread scheduler saat insiden terjadi.

1. Mengambil Thread Dump

Jalankan perintah berikut pada server target menggunakan PID aplikasi Java:

# Mendapatkan PID aplikasi Java
jcmd

# Mengambil thread dump via jcmd
jcmd <PID> Thread.print > threaddump.txt

# Alternatif menggunakan jstack
jstack -l <PID> > threaddump.txt

2. Menganalisis Status Thread scheduling-1

Buka file dump dan cari thread dengan prefix scheduling-. Pola task starvation umumnya menunjukkan trace seperti berikut:

"scheduling-1" #24 prio=5 os_prio=0 tid=0x00007f9c8402c800 nid=0x1b03 runnable [0x00007f9c6c5fd000]
   java.lang.Thread.State: RUNNABLE
	at java.net.SocketInputStream.socketRead0(Native Method)
	at java.net.SocketInputStream.socketRead(SocketInputStream.java:115)
	at java.net.SocketInputStream.read(SocketInputStream.java:168)
	at org.apache.http.impl.io.SessionInputBufferImpl.fillBuffer(SessionInputBufferImpl.java:151)
	at org.apache.http.impl.io.SocketInputBuffer.fillBuffer(SocketInputBuffer.java:82)
	at org.apache.http.impl.io.SessionInputBufferImpl.readLine(SessionInputBufferImpl.java:284)
	at com.example.service.ReportSyncService.fetchExternalData(ReportSyncService.java:45)
	at com.example.scheduler.SyncScheduler.runDailySync(SyncScheduler.java:28)
	- locked <0x0000000715a2b108> (a java.lang.Object)
	at jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
	at org.springframework.scheduling.support.ScheduledMethodRunnable.run(ScheduledMethodRunnable.java:84)
	at org.springframework.scheduling.support.DelegatingErrorHandlingRunnable.run(DelegatingErrorHandlingRunnable.java:54)
	at java.util.concurrent.Executors$RunnableAdapter.call(Executors.java:515)
	at java.util.concurrent.FutureTask.runAndReset(FutureTask.java:305)
	at java.util.concurrent.ScheduledThreadPoolExecutor$ScheduledFutureTask.run(ScheduledThreadPoolExecutor.java:305)
	at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1128)
	at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:628)
	at java.lang.Thread.run(Thread.java:829)

Thread di atas berstatus RUNNABLE namun tersangkut pada native call SocketInputStream.socketRead0. Karena thread pool berukuran 1, operasi read yang menggantung ini melumpuhkan seluruh siklus scheduling aplikasi.

Solusi 1: Konfigurasi Pool Size via Properties

Pendekatan paling cepat adalah mendefinisikan ukuran pool scheduler pada file konfigurasi aplikasi.

# application.properties
spring.task.scheduling.pool.size=5
spring.task.scheduling.thread-name-prefix=scheduled-worker-
spring.task.scheduling.shutdown.await-termination=true
spring.task.scheduling.shutdown.await-termination-period=30s

Atau jika menggunakan YAML:

# application.yml
spring:
  task:
    scheduling:
      pool:
        size: 5
      thread-name-prefix: scheduled-worker-
      shutdown:
        await-termination: true
        await-termination-period: 30s

Properti ini memerintahkan TaskSchedulingAutoConfiguration untuk menginstansiasi ThreadPoolTaskScheduler dengan worker pool sejumlah 5 thread. Nilai await-termination memastikan graceful shutdown berjalan saat aplikasi dimatikan.

Solusi 2: Registrasi Custom ThreadPoolTaskScheduler Bean

Jika Anda memerlukan kontrol penuh atas error handling, custom thread rejection, atau pemantauan metrik, deklarasikan bean ThreadPoolTaskScheduler secara eksplisit.

package com.example.config;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
import org.springframework.util.ErrorHandler;

@Configuration
@EnableScheduling
public class SchedulingConfig {

    private static final Logger log = LoggerFactory.getLogger(SchedulingConfig.class);

    @Bean
    public ThreadPoolTaskScheduler taskScheduler() {
        ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
        scheduler.setPoolSize(10);
        scheduler.setThreadNamePrefix("app-scheduler-");
        scheduler.setWaitForTasksToCompleteOnShutdown(true);
        scheduler.setAwaitTerminationSeconds(30);
        scheduler.setErrorHandler(new ScheduledTaskErrorHandler());
        scheduler.initialize();
        return scheduler;
    }

    private static class ScheduledTaskErrorHandler implements ErrorHandler {
        @Override
        public void handleError(Throwable t) {
            log.error("Unhandled exception caught in scheduled task: {}", t.getMessage(), t);
        }
    }
}

Mengimplementasikan custom ErrorHandler krusial untuk mencegah scheduler menghentikan trigger eksekusi di masa depan akibat RuntimeException yang tidak ditangkap (uncaught) di dalam logic task.

Best Practices Mencegah Starvation Lanjutan

Meningkatkan pool size saja tidak menyelesaikan akar masalah jika I/O blocking tidak dibatasi. Terapkan proteksi berikut:

  1. Enforce Timeout Ketat: Selalu konfigurasikan connection timeout dan read timeout pada HTTP Client (misal: RestTemplate, WebClient, atau HttpClient) dan JDBC driver. Jangan biarkan socket menunggu tanpa batas waktu.
  2. Pisahkan Scheduler dari Execution Work: Untuk proses batch berdurasi lama, gunakan @Scheduled hanya sebagai pemicu (dispatcher). Eksekusi proses berat tersebut ke thread pool terpisah menggunakan @Async dengan ThreadPoolTaskExecutor khusus:
@Scheduled(cron = "0 0 * * * *")
public void triggerBatchProcess() {
    // Task scheduler selesai dalam hitungan milidetik
    heavyProcessingService.processDataAsync();
}

Pola delegasi asinkron ini memisahkan ketersediaan thread trigger dari durasi beban kerja sebenarnya, meminimalkan risiko starvation pada sistem penjadwalan.