@Configuration
@EnableScheduling
@EnableConfigurationProperties(CleanupProperties.class)
public class SchedulingConfig {
@Bean(destroyMethod = "shutdown")
public ThreadPoolTaskScheduler taskScheduler() {
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
scheduler.setPoolSize(4);
scheduler.setThreadNamePrefix("cleanup-sched-");
scheduler.setWaitForTasksToCompleteOnShutdown(true);
scheduler.setAwaitTerminationSeconds(30);
scheduler.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
return scheduler;
}
}
@ConfigurationProperties(prefix = "cleanup")
public class CleanupProperties {
private String cron = "0 0 3 * * *";
private int batchSize = 500;
public String getCron() {
return cron;
}
public void setCron(String cron) {
this.cron = cron;
}
public int getBatchSize() {
return batchSize;
}
public void setBatchSize(int batchSize) {
this.batchSize = batchSize;
}
}
@Component
public class ExpiredTokenCleanupJob {
private static final Logger log = LoggerFactory.getLogger(ExpiredTokenCleanupJob.class);
private final TokenRepository tokens;
private final CleanupProperties props;
private final AtomicBoolean running = new AtomicBoolean(false);
public ExpiredTokenCleanupJob(TokenRepository tokens, CleanupProperties props) {
this.tokens = tokens;
this.props = props;
}
@Scheduled(cron = "#{@cleanupProperties.cron}", zone = "UTC")
public void purgeExpiredTokens() {
if (!running.compareAndSet(false, true)) {
log.warn("Previous cleanup run still active; skipping this tick");
return;
}
long start = System.currentTimeMillis();
long total = 0;
try {
int deleted;
do {
if (Thread.currentThread().isInterrupted()) {
log.info("Interrupted during shutdown; stopping after {} rows", total);
break;
}
deleted = tokens.deleteExpiredBatch(Instant.now(), props.getBatchSize());
total += deleted;
} while (deleted == props.getBatchSize());
log.info("Cleaned {} expired tokens in {} ms", total, System.currentTimeMillis() - start);
} catch (Exception ex) {
log.error("Token cleanup failed after {} rows", total, ex);
} finally {
running.set(false);
}
}
}
This snippet shows how a Spring Boot application runs a periodic maintenance task safely, without leaking threads or truncating in-flight work when the process stops. The core idea is to separate three concerns: the schedule trigger, the thread pool that actually runs the work, and the business logic itself.
In SchedulingConfig, @EnableScheduling turns on Spring's scheduling infrastructure, and a dedicated ThreadPoolTaskScheduler bean replaces the default single-threaded scheduler. Configuring it explicitly matters: the default @Scheduled executor is a one-thread pool, so a slow job can starve every other scheduled task. Setting setPoolSize gives headroom, and setThreadNamePrefix makes stack traces and logs legible. The two shutdown settings are the heart of the pattern — setWaitForTasksToCompleteOnShutdown(true) tells the pool to stop accepting new work but let running tasks finish, and setAwaitTerminationSeconds(30) bounds that wait so a stuck task cannot block JVM exit forever. Spring calls shutdown() on the bean automatically during context close because it implements DisposableBean.
CleanupProperties binds cleanup.* keys from configuration into a typed record-like holder, keeping the cron expression and batch size out of the code. Externalizing the cron string means the cadence can change per environment without a rebuild.
ExpiredTokenCleanupJob is the actual task. The @Scheduled(cron = ...) annotation references the property via SpEL, and zone pins interpretation to UTC so daylight-saving shifts never move the run time. The method itself is deliberately defensive: an AtomicBoolean guard (running) prevents overlapping executions if one run outlasts the interval, since @Scheduled on a fixed schedule does not skip a tick that arrives while the previous one is still busy. Work is done in bounded batches with deleteExpiredBatch, and the loop checks Thread.currentThread().isInterrupted() so that when the executor requests shutdown mid-run the job stops at a batch boundary rather than being killed halfway through a delete.
The trade-off is that graceful shutdown adds latency to redeploys, capped by awaitTerminationSeconds. This approach fits recurring cleanup, cache eviction, or aggregation jobs where correctness under shutdown matters more than instant termination; for heavier or distributed workloads a real job queue would be the better tool.
Related snips
export interface RetryOptions {
retries: number;
baseMs: number;
maxMs: number;
signal?: AbortSignal;
onRetry?: (attempt: number, delay: number, err: unknown) => void;
Exponential backoff with jitter for retries
package dbutil
import (
"context"
"github.com/jackc/pgconn"
Retry Postgres serialization failures with bounded attempts
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
type User {
id: ID!
name: String!
email: String!
posts: [Post!]!
createdAt: String!
GraphQL API with Spring Boot
use crossbeam::channel::unbounded;
use std::thread;
fn main() {
let (tx, rx) = unbounded();
Crossbeam for advanced concurrent data structures
// Creating a Promise
const myPromise = new Promise((resolve, reject) => {
const success = true;
setTimeout(() => {
if (success) {
Promises and async/await patterns for asynchronous JavaScript
Share this code
Here's the card — post it anywhere.