Tidak semua pekerjaan backend terjadi saat permintaan HTTP. Backup database tiap malam, pengiriman email ringkasan harian, pembersihan sesi kedaluwarsa — semua adalah tugas latar belakang yang dijadwalkan. Spring menyediakan @Scheduled untuk kasus sederhana dan Quartz scheduler untuk kebutuhan kompleks. Pada chapter ini kita akan memahami keduanya serta cron expression yang menjadi bahasa universal penjadwalan.

@Scheduled Spring #

Anotasi @Scheduled di Spring menjadwalkan method untuk dieksekusi otomatis. Tiga parameter utama:

  • fixedRate: eksekusi setiap N milidetik, terlepas dari durasi eksekusi sebelumnya.
  • fixedDelay: eksekusi N milidetik SETELAH eksekusi sebelumnya selesai.
  • cron: ekspresi cron untuk jadwal kompleks.
kotlin @Scheduled
@Component
class ReportService {
    @Scheduled(cron = "0 9 * * * *")  // Setiap hari jam 9:00
    fun generateDailyReport() { ... }

    @Scheduled(fixedDelay = 60_000)  // Setiap 1 menit setelah eksekusi selesai
    fun cleanupExpiredSessions() { ... }
}

Untuk mengaktifkan @Scheduled, tambahkan @EnableScheduling di class @SpringBootApplication. Spring akan memindai method @Scheduled dan mendaftarkannya ke TaskScheduler.

Cron Expression #

Cron expression adalah string yang menentukan jadwal. Format tradisional punya 5 field, Spring menambahkan field detik di depan menjadi 6 field:

text Format cron
*    *    *    *    *    *
|    |    |    |    |    |
|    |    |    |    |    └── day of week (0-6, Minggu=0)
|    |    |    |    └── month (1-12)
|    |    |    └── day of month (1-31)
|    |    └── hour (0-23)
|    └── minute (0-59)
└── second (0-59, Spring saja)

Setiap field dapat berisi: * (semua nilai), angka tunggal (5), range (0-10), list (1,5,10), atau step (*/5 artinya setiap 5 unit).

Contoh Cron #

CronArti
0 9 * * * *Setiap hari jam 9:00:00
*/5 * * * * *Setiap 5 detik
0 0 * * 0Setiap hari Minggu 00:00
30 2 * * *Setiap hari jam 02:30
0 9 * * MONSetiap Senin jam 9:00

Quartz memakai format 6-7 field (tambahan tahun opsional di akhir). Spring @Scheduled memakai format 6 field (detik di depan).

Quartz Scheduler #

Quartz adalah library scheduler yang lebih kaya dari @Scheduled. Quartz mendukung persistence (JobStore JDBC), clustering (multiple instance menjalankan scheduler yang sinkron), dan job listener. Quartz memisahkan konsep Job (apa yang dijalankan) dari Trigger (kapan dijalankan).

kotlin Quartz Job
class BackupJob : Job {
    override fun execute(context: JobExecutionContext) {
        // Logika backup database
    }
}

val job = JobBuilder.newJob(BackupJob::class.java)
    .withIdentity("nightlyBackup", "group1")
    .build()

val trigger = TriggerBuilder.newTrigger()
    .withIdentity("nightlyTrigger", "group1")
    .withSchedule(CronScheduleBuilder.cronSchedule("30 2 * * *"))
    .build()

scheduler.scheduleJob(job, trigger)

Demo Scheduler #

Kita akan memodelkan scheduler sebagai JobStore yang menyimpan job dan mengeksekusinya saat tick(time) dipanggil. Cron expression diparse menjadi set nilai valid per field, lalu dicocokkan dengan waktu.

kotlin
// background_jobs.kt
// Mendemonstrasikan penjadwalan tugas latar belakang: @Scheduled, Quartz
// scheduler, JobStore, dan cron expression. Pada kode produksi, Spring
// Scheduler memproses @Scheduled; Quartz memakai JobDetail + Trigger.
// Di sini kita memodelkannya sebagai scheduler ringkas yang mendukung
// cron expression sederhana.

import java.time.LocalDateTime
import java.time.format.DateTimeFormatter

// Mock anotasi Spring
@Target(AnnotationTarget.FUNCTION) annotation class Scheduled(val cron: String = "")

// Cron field: menit, jam, hari-bulan, bulan, hari-minggu
data class CronExpression(val expr: String) {
    private val parts = expr.split(" ")
    val minutes: Set<Int> = parseField(parts.getOrElse(0) { "*" }, 0, 59)
    val hours: Set<Int> = parseField(parts.getOrElse(1) { "*" }, 0, 23)
    val dayOfMonth: Set<Int> = parseField(parts.getOrElse(2) { "*" }, 1, 31)
    val month: Set<Int> = parseField(parts.getOrElse(3) { "*" }, 1, 12)
    val dayOfWeek: Set<Int> = parseField(parts.getOrElse(4) { "*" }, 0, 6)

    // * artinya semua nilai valid; angka tunggal artinya nilai spesifik;
    // */N artinya kelipatan N mulai dari min
    private fun parseField(field: String, min: Int, max: Int): Set<Int> = when {
        field == "*" -> (min..max).toSet()
        field.startsWith("*/") -> {
            val step = field.removePrefix("*/").toInt()
            (min..max step step).toSet()
        }
        else -> setOf(field.toInt())
    }

    fun matches(time: LocalDateTime): Boolean =
        time.minute in minutes &&
        time.hour in hours &&
        time.dayOfMonth in dayOfMonth &&
        time.monthValue in month &&
        time.dayOfWeek.value % 7 in dayOfWeek
}

// Job yang akan dieksekusi scheduler
data class JobDetail(val name: String, val cron: CronExpression, val action: () -> String)

// Quartz-style JobStore: menyimpan job dan trigger
class JobStore {
    private val jobs = mutableListOf<JobDetail>()
    private val executions = mutableListOf<Pair<LocalDateTime, String>>()

    fun addJob(job: JobDetail) { jobs.add(job) }

    // Tick scheduler: jalankan semua job yang cocok dengan waktu saat ini
    fun tick(time: LocalDateTime) {
        for (job in jobs) {
            if (job.cron.matches(time)) {
                val result = job.action()
                executions.add(time to "[${job.name}] $result")
            }
        }
    }

    fun executions(): List<Pair<LocalDateTime, String>> = executions.toList()
    fun jobCount(): Int = jobs.size
}

// Contoh service dengan @Scheduled
class ReportService {
    @Scheduled(cron = "0 9 * * * *")  // Setiap jam 9:00
    fun generateDailyReport(): String = "Daily report generated"

    @Scheduled(cron = "0 0 * * 0")  // Setiap hari Minggu 00:00
    fun generateWeeklyReport(): String = "Weekly report generated"

    @Scheduled(cron = "*/5 * * * *")  // Setiap 5 menit (simplified: menit kelipatan 5)
    fun cleanupExpiredSessions(): String = "Expired sessions cleaned"
}

class BackupService {
    @Scheduled(cron = "30 2 * * *")  // Setiap hari 02:30
    fun nightlyBackup(): String = "Database backed up to /backup/db.tar.gz"
}

fun main() {
    val store = JobStore()
    val reports = ReportService()
    val backups = BackupService()

    // Registrasi job dari service
    store.addJob(JobDetail("daily-report", CronExpression("0 9 * * *"), reports::generateDailyReport))
    store.addJob(JobDetail("weekly-report", CronExpression("0 0 * * 0"), reports::generateWeeklyReport))
    store.addJob(JobDetail("cleanup-sessions", CronExpression("*/5 * * * *"), reports::cleanupExpiredSessions))
    store.addJob(JobDetail("nightly-backup", CronExpression("30 2 * * *"), backups::nightlyBackup))

    println("=== Job terdaftar: ${store.jobCount()} ===")

    // Simulasi beberapa waktu
    val times = listOf(
        LocalDateTime.of(2026, 1, 15, 9, 0),   // daily report + cleanup
        LocalDateTime.of(2026, 1, 15, 9, 5),   // cleanup
        LocalDateTime.of(2026, 1, 15, 2, 30),  // nightly backup
        LocalDateTime.of(2026, 1, 18, 0, 0),   // weekly report (Sunday)
        LocalDateTime.of(2026, 1, 18, 9, 0),  // daily report (Sunday 9am)
        LocalDateTime.of(2026, 1, 19, 14, 25)  // no jobs
    )

    val fmt = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")
    println("\n=== Simulasi tick ===")
    times.forEach { t ->
        store.tick(t)
        println("  Ticked: ${t.format(fmt)}")
    }

    println("\n=== Eksekusi yang tercatat ===")
    store.executions().forEach { (time, msg) ->
        println("  ${time.format(fmt)} -> $msg")
    }

    println("\n=== Cron expression reference ===")
    listOf(
        "0 9 * * *" to "Setiap hari jam 9:00",
        "*/5 * * * *" to "Setiap 5 menit",
        "0 0 * * 0" to "Setiap Minggu 00:00",
        "30 2 * * *" to "Setiap hari 02:30"
    ).forEach { (cron, desc) ->
        println("  $cron -> $desc")
    }
}

Output yang Dihasilkan:

text
=== Job terdaftar: 4 ===

=== Simulasi tick ===
  Ticked: 2026-01-15 09:00
  Ticked: 2026-01-15 09:05
  Ticked: 2026-01-15 02:30
  Ticked: 2026-01-18 00:00
  Ticked: 2026-01-18 09:00
  Ticked: 2026-01-19 14:25

=== Eksekusi yang tercatat ===
  2026-01-15 09:00 -> [daily-report] Daily report generated
  2026-01-15 09:00 -> [cleanup-sessions] Expired sessions cleaned
  2026-01-15 09:05 -> [cleanup-sessions] Expired sessions cleaned
  2026-01-15 02:30 -> [cleanup-sessions] Expired sessions cleaned
  2026-01-15 02:30 -> [nightly-backup] Database backed up to /backup/db.tar.gz
  2026-01-18 00:00 -> [weekly-report] Weekly report generated
  2026-01-18 00:00 -> [cleanup-sessions] Expired sessions cleaned
  2026-01-18 09:00 -> [daily-report] Daily report generated
  2026-01-18 09:00 -> [cleanup-sessions] Expired sessions cleaned
  2026-01-19 14:25 -> [cleanup-sessions] Expired sessions cleaned

=== Cron expression reference ===
  0 9 * * * -> Setiap hari jam 9:00
  */5 * * * * -> Setiap 5 menit
  0 0 * * 0 -> Setiap Minggu 00:00
  30 2 * * * -> Setiap hari 02:30

Mari kita bedah bagian penting kode di atas:

  1. data class CronExpression(val expr: String): model cron expression dengan 5 field. Property minutes, hours, dayOfMonth, month, dayOfWeek adalah set nilai yang valid — di-parse saat konstruktor dipanggil.
  2. fun parseField(field, min, max): Set<Int>: parser 3 kasus: * → semua nilai dalam range; */N → kelipatan N; angka tunggal → set dengan satu elemen. Pada produksi, parser cron lengkap menangani list (1,5,10), range (0-10), dan nama hari/bulan.
  3. fun matches(time: LocalDateTime): Boolean: cek apakah setiap field cocok. time.dayOfWeek.value % 7 mengonversi LocalDateTime (Senin=1, Minggu=7) ke konvensi cron (Minggu=0, Senin=1, …, Sabtu=6).
  4. fun tick(time: LocalDateTime): eksekusi semua job yang cron cocok dengan waktu. Job dieksekusi sinkron — pada produksi, eksekusi paralel via thread pool.
  5. store.addJob(JobDetail("daily-report", CronExpression("0 9 * * *"), reports::generateDailyReport)): registrasi job dengan callable reference reports::generateDailyReport. Pada Spring, anotasi @Scheduled dipindai otomatis — tidak perlu registrasi manual.

Idempotency Job #

Job yang dijadwalkan dapat berjalan dua kali bila instance restart tepat saat jadwal. Untuk job yang tidak idempoten (mis. transfer uang), gunakan distributed lock (Redis Redlock, database advisory lock) agar hanya satu instance yang menjalankan job pada satu waktu.

Referensi Lanjutan #

  • Spring Boot reference — rujukan resmi Spring Boot untuk @Scheduled task dan eksekusi pekerjaan latar belakang
  • Ktor welcome — halaman Ktor sebagai dasar menjalankan background coroutine lewat Application scope

Selanjutnya #

Setelah tugas latar belakang berjalan, langkah terakhir adalah memastikan aplikasi siap produksi. Chapter Backend Production Readiness akan membahas health check, metrics, graceful shutdown, dan structured logging.