CicloUrbana is now observable and knows which environment it lives in, but it is still purely reactive: it only does something when somebody asks it to. Nobody closes at dawn the rentals a Ribalta citizen forgot to finish, nobody recalculates the occupancy of the four stations for the mobile app, and the confirmation e-mail is sent on the very thread serving the request, forcing the citizen to wait for the SMTP server to answer.
This lesson gives it initiative of its own. These are two distinct capabilities that are worth not confusing: scheduling work so that it happens at specific moments, and executing work off the thread that serves the request. We will look at both with their real traps — the single-threaded scheduler, the task that runs four times when you scale to four instances, the transaction that does not travel to the other thread, the security context left behind — and finish with Java 21's virtual threads and graceful shutdown.
| Scheduled tasks | Asynchronous execution | |
|---|---|---|
| Annotations | @EnableScheduling + @Scheduled |
@EnableAsync + @Async |
| Who triggers the work | An internal clock | A call from another thread |
| Question it answers | When should this happen? | Who should wait for it to happen? |
| Example in CicloUrbana | Expiring rentals every 10 minutes | Sending the confirmation e-mail |
| Default executor | A 1-thread ThreadPoolTaskScheduler |
SimpleAsyncTaskExecutor, no pool |
Contents
@EnableSchedulingand the three modes of@Scheduled- Cron expressions and time zones
- CicloUrbana's periodic tasks
- The single-threaded scheduler
- Scheduled tasks with several instances: ShedLock
- Testing scheduled tasks and
/actuator/scheduledtasks @EnableAsyncand@Async- The
Executor: why the default one is dangerous - Asynchrony and transactions
- Propagating context between threads
- Exceptions in asynchronous methods
- Java 21 virtual threads
- Graceful shutdown
- Common Mistakes and Tips
- Exercises
@EnableScheduling and the three modes of @Scheduled
@EnableScheduling and the three modes of @ScheduledThe capability is switched on with an annotation on any configuration class:
From then on, any argument-less public void method annotated with @Scheduled in a bean is registered with the scheduler. The three modes:
| Mode | When the next run is triggered | If one run takes longer than the interval |
|---|---|---|
fixedRate |
Every N ms from the start of the previous one | Delay accumulates; the next starts as soon as the current one ends |
fixedDelay |
N ms after the end of the previous one | Never overlaps; the real cadence degrades |
cron |
According to the expression, at absolute instants | Missed occurrences are skipped |
The difference between the first two is not theoretical. With fixedRate = 60_000 and a task that takes 90 seconds, the scheduler wants to run it every minute and cannot: with a single thread, runs chain together without a pause; with several threads, they overlap, and two copies of the occupancy recalculator writing at the same time are a race condition. With fixedDelay = 60_000 there is always a minute of rest between the end of one and the start of the next, and there is never any overlap.
The practical rule: fixedDelay for work whose duration is variable or unknown — which is nearly everything that touches a database — fixedRate only when cadence matters more than overlap and the task is fast and safe, and cron when the absolute moment matters (midnight, month end, rush hour).
@Scheduled(fixedDelay = 600_000, initialDelay = 60_000) // milliseconds
@Scheduled(fixedDelayString = "PT10M", initialDelayString = "PT1M") // ISO-8601
@Scheduled(fixedDelay = 10, initialDelay = 1, timeUnit = TimeUnit.MINUTES)The three lines do the same thing. initialDelay matters more than it looks: without it, every task starts at once the moment the context is ready, exactly when the application is warming caches and creating connections. Staggering them with different initial delays avoids a storm of work at the worst possible moment.
And the most important thing for maintenance: the ...String variants accept property substitution, which turns the cadence into configuration:
With that, the interval is tuned per environment with the profiles from 07-02 — five minutes in production, one second in a manual test — without recompiling. It is the recommended form for every task in CicloUrbana.
- Cron expressions and time zones
Spring's cron has six fields, not five: unlike Unix cron, it includes seconds.
┌─────────── second (0-59) │ ┌───────── minute (0-59) │ │ ┌─────── hour (0-23) │ │ │ ┌───── day of month (1-31) │ │ │ │ ┌─── month (1-12 or JAN-DEC) │ │ │ │ │ ┌─ day of week (0-7 or MON-SUN) 0 0 3 * * *
| Expression | Meaning |
|---|---|
0 */5 * * * * |
Every 5 minutes |
0 0 3 * * * |
Every day at 03:00 |
0 30 2 * * MON-FRI |
Monday to Friday at 02:30 |
0 0 8,14,20 * * * |
At 8, at 14 and at 20 |
0 0 0 1 * * |
On the 1st of each month at midnight |
0 0 0 L * * |
The last day of the month (a Spring extension) |
0 0 6 * * SAT#2 |
The second Saturday of each month at 6 |
There are also readable macros — @yearly, @monthly, @weekly, @daily (equivalent to 0 0 0 * * *) and @hourly — and the - extension, which disables a task without deleting it; combined with a property it is very handy: @Scheduled(cron = "${ciclourbana.reports.cron:-}") leaves the report switched off except in environments that define the expression.
Time zones are the quietest source of bugs in this whole section. Without zone, the expression is interpreted in the JVM's zone, which in a container is usually UTC even though the team is in Ribalta. A report scheduled at 0 0 3 * * * is then generated at 5 in the morning local time in summer, and nobody notices until somebody compares figures. The correct form is @Scheduled(cron = "0 0 3 * * *", zone = "Europe/Madrid").
And once the zone is declared, the daylight saving change problem appears:
| Date | What happens | Effect on 0 30 2 * * * |
|---|---|---|
| Last Sunday in March | At 02:00 the clock jumps to 03:00 | The task does not run: that hour does not exist |
| Last Sunday in October | 02:00-03:00 happens twice | The task runs only once, but shifted |
The defence is not technical but a matter of design: schedule critical work outside the 01:00-03:00 window — 04:15 is a perfectly good time — and make the tasks idempotent, so that running them twice or not at all corrupts nothing. A task that adds amounts onto a running total is dangerous; one that recalculates the total from the source data is not.
- CicloUrbana's periodic tasks
RentalExpirer closes the rentals that exceed the maximum duration configured in NetworkProperties (02-05). A citizen who leaves the bike and forgets to finish would block plate RB-0142 indefinitely.
package com.ciclourbana.rentals;
@Component
public class RentalExpirer {
private final RentalService rentalService;
private final NetworkProperties network;
private final Clock clock; // constructor omitted
@Scheduled(fixedDelayString = "${ciclourbana.rentals.expirer.interval:PT10M}",
initialDelayString = "PT1M")
public void run() {
int closed = expireOverdueRentals();
if (closed > 0) {
log.info("Expired {} rentals exceeding {}", closed,
network.maximumRentalDuration());
}
}
/** Logic callable directly from a test, without waiting for the clock. */
public int expireOverdueRentals() {
Instant cutoff = Instant.now(clock).minus(network.maximumRentalDuration());
return rentalService.closeExpired(cutoff);
}
}Four decisions. The interval is a property with a default value, so it is tuned per environment. The logic lives in a separate public method, which is what makes the task testable (section 6). Clock is injected, following the practice from 06-02: with Clock.fixed the test controls time. And it only logs when there is work, because a task that writes "I did nothing" every ten minutes turns the log into noise and hides what matters.
The second task, OccupancyRecalculator, keeps the summary consumed by the mobile app up to the minute with @Scheduled(fixedDelayString = "${ciclourbana.stations.occupancy.interval:PT1M}") over a call to stationService.refreshOccupancySummary(). Here fixedDelay is again the right choice: if one day the query takes 90 seconds because the database is loaded, the last thing we want is a second copy recalculating on top of the first.
- The single-threaded scheduler
This is the trap that surprises everybody the first time. The TaskScheduler that Spring Boot configures by default has exactly one thread. With two tasks, if RentalExpirer takes three minutes because the database is slow, OccupancyRecalculator does not run during those three minutes: it waits its turn. The symptom is baffling — a task that "sometimes does not run" — and the cause is in a completely different task.
The basic solution is a property:
spring:
task:
scheduling:
pool:
size: 4
thread-name-prefix: ciclo-task-
shutdown:
await-termination: true
await-termination-period: 30sAnd when fine control is needed, your own bean:
@Bean
TaskScheduler taskScheduler() {
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
scheduler.setPoolSize(4);
scheduler.setThreadNamePrefix("ciclo-task-");
scheduler.setWaitForTasksToCompleteOnShutdown(true);
scheduler.setAwaitTerminationSeconds(30);
scheduler.setErrorHandler(t -> log.error("Scheduled task failed", t));
return scheduler;
}Two warnings about the pool size. More threads is not always better: with fixedRate and several threads, a slow task can overlap with itself, something that was impossible with a single thread. And the thread-name-prefix is not cosmetic: when in 09-05 you have to search the log for which thread held a connection, a thread called ciclo-task-2 says far more than pool-3-thread-1.
The setErrorHandler deserves separate attention: an uncaught exception in a @Scheduled task does not stop the application, but it does cancel that task's future runs when it escapes to the scheduler. The result is a task that silently stops running. An explicit error handler, or a try/catch inside the method, avoids that ending.
- Scheduled tasks with several instances: ShedLock
When CicloUrbana moves to three replicas in 08-04, each one will have its own scheduler and every task will run three times. For OccupancyRecalculator that is wasted work; for a task that sends a summary e-mail to citizens, it is three e-mails; for one that charges surcharges, three charges.
| Solution | How it works | When to choose it |
|---|---|---|
A scheduler profile on a single instance |
Only that replica enables @EnableScheduling |
Simple, but that instance is a single point of failure |
| Database lock (ShedLock) | The first one to claim the row runs; the rest skip | The default option: no new infrastructure |
| External scheduler | A Kubernetes CronJob calls an endpoint |
Heavy work, or work that must outlive the application's lifecycle |
graph LR
B1[Instance 1<br/>takes the lock] -->|runs| DB[(shedlock table<br/>in PostgreSQL)]
B2[Instance 2] -.->|fails to get it:<br/>skips| DB
B3[Instance 3] -.->|fails to get it:<br/>skips| DB
ShedLock leans on the PostgreSQL we already have: two dependencies (shedlock-spring and shedlock-provider-jdbc-template, version 5.16.0) and a table created with a Flyway migration, consistent with 04-08:
-- V7__shedlock.sql
CREATE TABLE shedlock (
name VARCHAR(64) NOT NULL PRIMARY KEY,
lock_until TIMESTAMP NOT NULL,
locked_at TIMESTAMP NOT NULL,
locked_by VARCHAR(255) NOT NULL
);@Configuration
@EnableScheduling
@EnableSchedulerLock(defaultLockAtMostFor = "PT30M")
public class TaskConfig {
@Bean
LockProvider lockProvider(DataSource dataSource) {
return new JdbcTemplateLockProvider(JdbcTemplateLockProvider.Configuration.builder()
.withJdbcTemplate(new JdbcTemplate(dataSource))
.usingDbTime() // uses the DB server's clock, not the JVM's
.build());
}
}@Scheduled(cron = "0 0 3 * * *", zone = "Europe/Madrid")
@SchedulerLock(name = "ribaltaDailyReport",
lockAtLeastFor = "PT5M", lockAtMostFor = "PT25M")
public void generateDailyReport() { /* ... */ }The three parameters you have to understand. lockAtMostFor is the safety net: if the instance that took the lock dies without releasing it, the lock only expires after that time, so it must be longer than the task's maximum reasonable duration or two instances will end up running it at the same time. lockAtLeastFor holds the lock for a minimum even if the task finishes sooner, which protects against the case where two instances' clocks are slightly out of step and the second one fires the task two seconds later. And usingDbTime() makes PostgreSQL's clock the time reference, removing that skew at the root.
One last warning: ShedLock is not a mutual exclusion mechanism for the business. It guarantees that the task is not fired twice by the scheduler, not that two different code paths do not touch the same data. Transactions and the locks from 04-07 are still there for that.
- Testing scheduled tasks and
/actuator/scheduledtasks
/actuator/scheduledtasksTesting a task by waiting for the clock to trigger it is slow and fragile. The correct pattern is already applied in RentalExpirer: the annotation sits on a thin method that only delegates, and all the logic lives in a public method that the test calls directly.
class RentalExpirerTest {
private final Clock clock = Clock.fixed(
Instant.parse("2026-09-01T23:00:00Z"), ZoneOffset.UTC);
@Test
void closesRentalsThatExceedTheMaximumDuration() {
RentalService service = mock(RentalService.class);
given(service.closeExpired(any())).willReturn(3);
int closed = new RentalExpirer(service, networkProperties(), clock)
.expireOverdueRentals();
assertThat(closed).isEqualTo(3);
verify(service).closeExpired(Instant.parse("2026-09-01T21:00:00Z"));
}
}The assertion on the exact Instant is what gives the test its value: it checks that the cutoff is computed by subtracting the maximum duration, which is the real business rule. And it does not take ten minutes to run.
To verify that the task is registered with the expected cadence there are two routes. In tests, @SpringBootTest with @MockitoBean over the service and Awaitility waiting for it to be invoked, with the interval lowered to PT0.1S by property. And at runtime, the endpoint from 07-01:
curl -s -u admin:*** http://localhost:8081/actuator/scheduledtasks | jq '.fixedDelay'
# [ { "runnable": { "target": "...RentalExpirer.run" },
# "initialDelay": 60000, "interval": 600000 } ]It is the quickest way to answer "why hasn't the report run?": if the task does not appear in that list, it is not registered, and the cause is usually a missing @EnableScheduling, a profile that did not activate the bean, or a method that is not public.
@EnableAsync and @Async
@EnableAsync and @AsyncThe second half of the lesson changes the question: it is no longer when the work happens, but who waits for it to happen.
An @Async method returns control immediately and its body runs on another thread. The supported return types:
| Return | Semantics | Use |
|---|---|---|
void |
Fire and forget; the caller does not know whether it finished or failed | Notifications, auditing, sending e-mail |
CompletableFuture<T> |
The caller can compose, wait and capture errors | Parallel calls that have to be combined |
Future<T> |
The same but with the old API, without composition | Legacy code |
@Async
public CompletableFuture<StationSummary> summary(Long stationId) {
return CompletableFuture.completedFuture(stationService.summary(stationId));
}Note that the method returns an already completed future: it is not the method that completes it, it is the proxy. It is counter-intuitive but correct; the value is wrapped when the method ends, and the method is already running on the executor's thread.
And here exactly the same proxy traps as with @Transactional (04-07) come back, for the same reason: @Async is implemented with a proxy that wraps the bean.
| Trap | What happens | Fix |
|---|---|---|
| Self-invocation | this.sendMail() does not go through the proxy: it runs synchronously, with no warning at all |
Move the asynchronous method to another bean and inject it |
private, final or static method |
Not interceptable; it runs synchronously | It must be public and not final |
Call from the constructor or @PostConstruct |
The proxy is not wired up yet | Use ApplicationReadyEvent (01-05) |
| Returning an arbitrary type | The caller receives null, because the real value is computed later |
Only void, Future or CompletableFuture |
The first is by far the most frequent and the hardest to spot: the code works, the tests pass, and the only thing that happens is that the asynchrony does not exist. The symptom in production is a latency that will not come down no matter how many threads you add.
- The
Executor: why the default one is dangerous
Executor: why the default one is dangerousWithout explicit configuration, Spring Boot uses the applicationTaskExecutor created by the autoconfiguration, a ThreadPoolTaskExecutor with generous defaults. But if that bean does not exist — somebody defines their own badly registered Executor, or you are working without Boot — the fallback is SimpleAsyncTaskExecutor, and that is dangerous: it creates a new thread per invocation and does not reuse them. Under load, a thousand requests a minute means a thousand threads, each with its one-megabyte stack, and the JVM runs out of memory. There is no queue, no limit and no back pressure.
spring:
task:
execution:
pool: { core-size: 8, max-size: 24, queue-capacity: 200, keep-alive: 60s }
thread-name-prefix: ciclo-async-
shutdown: { await-termination: true, await-termination-period: 30s }Or as a bean, when a rejection policy or decorators are needed:
@Bean("mailExecutor")
Executor mailExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(4);
executor.setMaxPoolSize(8);
executor.setQueueCapacity(500);
executor.setThreadNamePrefix("ciclo-mail-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(30);
executor.initialize();
return executor;
}It is chosen by name with @Async("mailExecutor"). Having one executor per kind of work is a form of bulkhead (07-06): if the mail server jams, its threads run out without dragging down the rest of the application.
The pool's behaviour is not intuitive and is worth memorising, because it explains ninety per cent of the surprises: while there are fewer threads than core-size, every task creates a new one; once core-size is reached, tasks go to the queue; only when the queue is full are threads created up to max-size; and once both are exhausted, the rejection policy kicks in. The awkward corollary: a large queue-capacity means max-size is almost never used. With core-size: 8 and queue-capacity: 10000, the pool will never go beyond eight threads however much load arrives; it will simply pile up ten thousand pending tasks. If what you want is for it to grow under pressure, the queue must be small.
| Rejection policy | What it does | Effect |
|---|---|---|
CallerRunsPolicy |
The calling thread runs it | Back pressure: whoever produces work slows itself down |
AbortPolicy (default) |
Throws RejectedExecutionException |
Fails loudly; you have to catch it |
DiscardPolicy |
Silently discards | Almost never acceptable: it loses work without warning |
DiscardOldestPolicy |
Discards the oldest one in the queue | Only where recency matters more |
For CicloUrbana's e-mail, CallerRunsPolicy is the reasonable choice: if the system saturates, the e-mail is sent on the request thread — the citizen waits a little — instead of being lost.
- Asynchrony and transactions
Here is the most expensive conceptual mistake in the module. @Async and @Transactional together almost never do what you expect, because Spring's transaction lives in a ThreadLocal and does not travel to the executor's thread. With both annotations on the same method, the calling thread launches the task and carries on, and the executor's thread opens a completely new transaction: it does not see the caller's uncommitted writes and commits or rolls back on its own. If the caller rolls back, the asynchronous work has already run and committed anyway. And if the asynchronous method receives a managed entity as an argument, that entity belongs to another thread's EntityManager: LazyInitializationException guaranteed.
The three rules that solve the problem:
- Pass identifiers, never managed entities. The asynchronous method reloads what it needs in its own transaction.
- Fire the asynchronous work after the commit, not inside the transaction.
- Let the asynchronous method's transaction be its own, opened inside its thread.
The combination CicloUrbana applies joins this with @TransactionalEventListener from 04-07:
@Component
public class RentalNotifier {
private final MailService mail; // constructor omitted
@Async("mailExecutor")
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onConfirmed(RentalStarted event) {
mail.sendConfirmation(event.rentalId(), event.citizenEmail());
}
}sequenceDiagram
participant C as Citizen
participant H as HTTP thread
participant DB as PostgreSQL
participant E as mailExecutor
C->>H: POST /api/v1/rentals
H->>DB: INSERT rental + COMMIT
H-->>C: 201 Created (42 ms)
Note over H,E: AFTER_COMMIT + @Async
H->>E: publishes the task and releases the thread
E->>E: sends the e-mail over SMTP (1.8 s)
Why it is the right combination. AFTER_COMMIT guarantees that a rental that is later rolled back is never announced, the problem we already identified in 04-07. @Async guarantees that the citizen does not wait for the SMTP server: the response goes out in tens of milliseconds. And the event carries plain data — the identifier and the e-mail address — not the Rental entity, avoiding the closed-session problem.
One honest nuance remains: with this scheme, if sending fails, the response has already been given as successful. It is a deliberate decision — the rental is valid even if the e-mail never arrives — and the trade-off is that the failure must be logged and watched. An @Async returning void has no retries and no durability: if the process dies with tasks in the executor's queue, those tasks are lost. When that loss is not acceptable, the answer is a real message queue, which we will see in 07-05 and 07-06.
- Propagating context between threads
Two pieces of CicloUrbana live in ThreadLocal and do not cross on their own to the executor's thread: the SecurityContextHolder from 05-01 and the MDC of the TraceFilter from 03-06. Hence two classic symptoms: a @PreAuthorize that fails inside an asynchronous method because there is no authentication, and background-work log lines that come out with [no-trace] and cannot be related to the request that produced them.
For security, Spring Security ships an executor decorator: it is enough to wrap the already-configured ThreadPoolTaskExecutor in a DelegatingSecurityContextAsyncTaskExecutor before returning it as a bean. There is also SecurityContextHolder.setStrategyName(MODE_INHERITABLETHREADLOCAL), which makes child threads inherit the context, but it is a risky option: it only acts when the thread is created, so in a pool that reuses threads the inherited context is that of the first job that created the thread, not the current one. With pools it is worse than doing nothing; it is only useful for hand-created threads.
For the MDC, a custom TaskDecorator copies the diagnostic map:
public class MdcDecorator implements TaskDecorator {
@Override
public Runnable decorate(Runnable task) {
Map<String, String> context = MDC.getCopyOfContextMap(); // calling thread
return () -> {
Map<String, String> previous = MDC.getCopyOfContextMap();
try {
if (context != null) MDC.setContextMap(context);
task.run();
} finally {
if (previous != null) MDC.setContextMap(previous); else MDC.clear();
}
};
}
}It is registered with executor.setTaskDecorator(new MdcDecorator()). The key is in the two halves: the copy is made when decorating, that is, on the thread that submits the task, and the restoration in the finally is essential for the same reason as the MDC.remove() in 03-06 — without it, the trace identifier sticks to the pool's thread and contaminates the following tasks.
With both pieces in place, a log line from sending the e-mail carries the same traceId as the POST /api/v1/rentals request that produced it, which is exactly what will be needed for the distributed tracing in 09-06.
- Exceptions in asynchronous methods
An @Async method that returns CompletableFuture hands its exception to the caller when the caller does join() or get(). But one that returns void has nobody to hand it to: by default the exception is logged and lost. To handle it centrally:
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return (ex, method, params) -> log.error(
"Asynchronous task {} failed with arguments {}",
method.getName(), Arrays.toString(params), ex);
}
}AsyncConfigurer also lets you return the default Executor in getAsyncExecutor(). The handler applies only to methods that return void; for those returning futures, the responsibility is the caller's, with handle or exceptionally. The practical consequence is a rule: if the result matters, return a future; if it does not matter, assume the failure will only reach the log and make sure that log is watched.
- Java 21 virtual threads
Java 21 introduces virtual threads: threads managed by the JVM rather than the operating system, so cheap that millions of them can be created. Spring Boot 3.2+ adopts them with a single property:
With it, the web server serves each request on a virtual thread, and the @Async and scheduler executors start creating one virtual thread per task. The change of model is profound: you no longer have to size pools for blocking loads, because blocking a virtual thread does not consume an operating system thread.
| Kind of load | Worth it? | Reason |
|---|---|---|
| Blocking I/O: JDBC, HTTP, SMTP | Yes, this is their ideal case | Thousands of tasks waiting without exhausting OS threads |
| Compute-intensive | No benefit | The limit is cores, not threads |
| Already reactive applications (WebFlux) | Unnecessary | They already solve the problem another way |
And three warnings you must know before enabling them in Ribalta. First, pinning: a virtual thread blocked inside a synchronized block pins its carrier thread and cancels out the advantage; you have to review your own code and older libraries and replace synchronized with ReentrantLock wherever blocking occurs. Second: the connection pool is still the real limit. A million virtual threads asking a twenty-connection HikariCP for connections speeds nothing up; it merely moves the queue elsewhere, and makes the bottleneck less visible. Third: ThreadLocal still works, but with one thread per task, ThreadLocal-based caches stop making sense and turn into memory leaks.
The recommendation for CicloUrbana: enable them in dev and pre, measure with the metrics from 09-03, and promote them to prod once the behaviour is confirmed. It is a one-line change, and precisely for that reason it is worth treating it as what it is: a change of execution model.
- Graceful shutdown
In 01-05 we saw the web server's graceful shutdown: it stops accepting new requests and waits for the in-flight ones to finish. The executors need their own equivalent configuration, or the queued work is discarded when the process stops:
server:
shutdown: graceful
spring:
lifecycle:
timeout-per-shutdown-phase: 40s
task:
execution:
shutdown:
await-termination: true
await-termination-period: 30s
scheduling:
shutdown:
await-termination: true
await-termination-period: 30sThe consistency rule: timeout-per-shutdown-phase must be greater than the await-termination-period values, or the context will close while the executors are still waiting. And all of it must fit inside the grace period the orchestrator allows before SIGKILL (30 seconds by default in Kubernetes, 08-04).
Even so, a graceful shutdown does not turn a queued task into a guaranteed one: if the process dies abruptly, it is lost. That is, once again, the boundary between @Async and a real message queue.
Common Mistakes and Tips
Self-invoking an @Async or @Scheduled method. The proxy does not step in and the method runs synchronously, with no error at all. It is the most frequent mistake in this lesson.
Forgetting that the scheduler has a single thread. One slow task blocks all the others, and the symptom appears in the wrong task.
Using fixedRate for work of variable duration. With several threads the task overlaps with itself; with one, delay accumulates. fixedDelay by default.
Scheduling critical tasks between 1 and 3 in the morning. The daylight saving change will make them skip one day and run shifted another.
Scaling to several instances without coordinating the tasks. Three replicas mean three runs: ShedLock, a dedicated profile or an external scheduler.
Combining @Async and @Transactional on the same method. The transaction does not travel to the other thread: a new, independent one is opened, and managed entities do not cross.
Assuming the SecurityContext or the MDC are present on the asynchronous thread. They are not: you need DelegatingSecurityContextAsyncTaskExecutor and a TaskDecorator.
Configuring a huge queue believing it increases parallelism. With a large queue, the pool never grows beyond core-size.
Tip: extract the logic of every scheduled task into a callable public method, which is what makes it testable in milliseconds instead of minutes, and make the tasks idempotent: with retries, clock changes and several instances, running twice is a real possibility.
Tip: give every one of your threads a name prefix. ciclo-mail-3 in a thread dump is worth half an hour of investigation.
Tip: make every cadence configurable with fixedDelayString = "${...}". Being able to lower an interval to one second in dev and to disable a task with - in an environment costs nothing.
Exercises
Exercise 1: nightly fleet maintenance task
Write FleetReviewer, a task that every day at 04:15 Ribalta time flags as MAINTENANCE the bikes whose battery level is below the batteryThreshold in NetworkProperties or that have done more than 300 rentals since their last review. It must be configurable, testable without waiting for the clock, safe with three instances running and it must not stop working if one run fails. Write the unit test of its logic as well.
Exercise 2: asynchronous confirmation e-mail, end to end
Build the sending of the rental confirmation e-mail: the dedicated executor with its rejection policy, the propagation of the MDC and the security context, the link to @TransactionalEventListener(AFTER_COMMIT) and exception handling. Explain what the citizen sees at each step and what happens if the SMTP server takes ten seconds, if it is down and if the application shuts down with e-mails queued.
Exercise 3: diagnosing three inexplicable behaviours
A colleague reports three problems in CicloUrbana. Diagnose each one and propose the fix.
@Service
public class MaintenanceService {
@Scheduled(fixedRate = 60_000)
public void review() {
List<Bike> bikes = repository.findAll();
for (Bike b : bikes) {
this.process(b); // (A) "it doesn't run in parallel"
}
}
@Async
@Transactional
public void process(Bike bike) {
bike.setLastReview(LocalDate.now());
repository.save(bike);
notifier.notifyWorkshop(bike.getPlate());
}
@Scheduled(cron = "0 0 2 * * *") // (B) "some days it skips"
public void nightlyReport() {
reportService.generate(); // (C) "it stopped running a month ago"
}
}Solutions
Solution 1
package com.ciclourbana.bikes;
@Component
public class FleetReviewer {
private static final int RENTALS_BETWEEN_REVIEWS = 300;
private final BikeRepository repository;
private final NetworkProperties network; // constructor omitted
@Scheduled(cron = "${ciclourbana.fleet.review.cron:0 15 4 * * *}", zone = "Europe/Madrid")
@SchedulerLock(name = "ribaltaFleetReview",
lockAtLeastFor = "PT2M", lockAtMostFor = "PT20M")
public void run() {
try {
int flagged = reviewFleet();
if (flagged > 0) {
log.info("Sent {} Ribalta bikes to maintenance", flagged);
}
} catch (Exception e) {
log.error("The nightly fleet review failed", e);
}
}
/** Pure logic, callable from a test. Returns how many bikes it changed. */
@Transactional
public int reviewFleet() {
List<Bike> candidates = repository.findDueForReview(
network.batteryThreshold(), RENTALS_BETWEEN_REVIEWS);
for (Bike bike : candidates) {
bike.setStatus(BikeStatus.MAINTENANCE); // idempotent
}
return candidates.size();
}
}The five requirements, one by one. Configurable: the cron expression is a property with a default value, so an environment can bring it forward or switch it off with -. At the right time: zone = "Europe/Madrid" fixes the reference, and 04:15 falls outside the dangerous daylight saving window. Testable: reviewFleet() is public and does not depend on the scheduler's clock. Safe with three instances: @SchedulerLock with a lockAtMostFor of twenty minutes, comfortably above the expected duration. Does not stop working after a failure: the try/catch prevents the exception from escaping to the scheduler and cancelling future runs.
Two additional details. The filtering lives in a repository query and not in a stream over findAll(): pulling the whole fleet into memory to discard 95 % of it is the anti-pattern from 04-06. And there is no save(): the entities are managed inside the transaction and the dirty checking from 04-07 generates the UPDATEs at commit time. Assigning the status to a bike that was already in maintenance changes nothing, which gives idempotency for free.
@Test
void flagsTheBikesNeedingReviewUsingTheConfiguredThreshold() {
BikeRepository repository = mock(BikeRepository.class);
NetworkProperties network = new NetworkProperties("Ribalta", 8, 20,
Duration.ofHours(2), List.of("Main Square"), false);
Bike lowBattery = bike("RB-0142", BikeStatus.AVAILABLE);
given(repository.findDueForReview(20, 300)).willReturn(List.of(lowBattery));
int flagged = new FleetReviewer(repository, network).reviewFleet();
assertThat(flagged).isEqualTo(1);
assertThat(lowBattery.getStatus()).isEqualTo(BikeStatus.MAINTENANCE);
}The assertion on findDueForReview(20, 300) verifies that the threshold comes from NetworkProperties and not from a hard-coded constant, which was the lesson of 02-05.
Solution 2
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
@Bean("mailExecutor")
Executor mailExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(4);
executor.setMaxPoolSize(8);
executor.setQueueCapacity(500);
executor.setThreadNamePrefix("ciclo-mail-");
executor.setTaskDecorator(new MdcDecorator());
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(30);
executor.initialize();
return new DelegatingSecurityContextAsyncTaskExecutor(executor);
}
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return (ex, method, params) -> log.error("Async failure in {} with {}",
method.getName(), Arrays.toString(params), ex);
}
}The listener is the RentalNotifier from section 9: @Async("mailExecutor") alongside @TransactionalEventListener(AFTER_COMMIT).
What the citizen sees. They send POST /api/v1/rentals; the HTTP thread opens the transaction, inserts the rental, marks the bike as rented and commits; at AFTER_COMMIT the task is published to the executor and the HTTP thread is released; the citizen gets their 201 Created in tens of milliseconds. Afterwards, on a ciclo-mail-N thread, with the same traceId in the MDC thanks to the decorator and with their authentication available thanks to the delegating executor, the e-mail is sent.
The three scenarios in the exercise. If SMTP takes ten seconds, the citizen is unaffected: their response went out long ago. What is consumed is one pool thread for ten seconds; with core-size: 4, from the fifth simultaneous confirmation onwards tasks are queued, and only with five hundred queued would the rejection policy kick in, running them on the calling thread. If the server is down, the exception rises to the AsyncUncaughtExceptionHandler, which logs it with the method and the arguments; the rental is still valid and the citizen never notices, which is a conscious business decision: e-mail confirmation is a convenience, not the contract. If the application shuts down with e-mails queued, the graceful shutdown from section 13 gives it up to thirty seconds to drain the queue; whatever does not fit in that window, or any pending task if the process dies outright, is lost without a trace. If that loss were unacceptable — an invoice, for instance — @Async would be the wrong tool and you would have to persist the intent (the outbox pattern, 07-05) or use a message queue.
Solution 3
(A) "It doesn't run in parallel". Self-invocation: this.process(b) does not go through the proxy, so @Async and @Transactional are ignored entirely and everything runs synchronously on the scheduler's thread, with no transaction at all. There is a second problem on top: even if it were fixed by moving process to another bean, a managed entity would be passed to another thread, with a LazyInitializationException waiting at the first lazy access. The fix is to move the method to another component and pass the identifier:
@Component
public class BikeProcessor {
@Async("maintenanceExecutor")
public void process(Long bikeId) {
transaction.execute(status -> { // or a @Transactional method of its own
Bike bike = repository.findById(bikeId).orElseThrow();
bike.setLastReview(LocalDate.now(clock));
return null;
});
notifier.notifyWorkshop(bikeId); // external call outside the transaction
}
}The notification to the workshop is left outside the transaction, for the reason given in 04-07: do not hold a connection during a network call.
(B) "Some days it skips". The expression 0 0 2 * * * runs at 2 in the morning in the JVM's zone, and it falls right in the daylight saving window: on the last Sunday in March that hour does not exist and the task does not run. The fix is twofold: declare zone = "Europe/Madrid" so that the time is the expected one, and move the run outside the critical window, for instance to 0 15 4 * * *.
(C) "It stopped running a month ago". This is the symptom of an exception that escaped the method: when a scheduled task throws and the exception reaches the scheduler, that task's future runs are cancelled, while the rest of the application carries on perfectly normally. It is confirmed in two steps: search a month-old log for the exception from reportService.generate(), and check in /actuator/scheduledtasks (07-01) that the task no longer appears registered. The fix has three layers: a try/catch inside the method, an ErrorHandler on the TaskScheduler as a global safety net, and an alert on the fact that the task is not running — because the silent failure mode of scheduled tasks is precisely that nobody misses them.
There is also a fourth problem the exercise does not mention: fixedRate = 60_000 over a findAll() of the whole fleet. If the review takes more than a minute, with a multi-threaded scheduler it overlaps with itself and two runs flag the same bikes at once. It should be fixedDelay, and the query should filter in the database.
Conclusion
CicloUrbana now has initiative of its own. You know how to switch scheduling on with @EnableScheduling and how to choose sensibly between fixedRate, fixedDelay and cron, understanding what happens when one run lasts longer than its interval, and you know how to make any cadence configurable with the ...String variants and a property, including the trick of disabling a task with -. You have mastered Spring's six cron fields, its macros and its extensions, and you have the two lessons about time committed to memory: always declare the time zone and schedule critical work outside the daylight saving window, with idempotent tasks that survive running twice or not at all. RentalExpirer and OccupancyRecalculator work, they are written so that they can be tested in milliseconds and they show up in /actuator/scheduledtasks when you have to check why something did not run.
You know the two big traps on the task side: the single-threaded scheduler, which makes one slow task block all the others and makes the symptom appear in the wrong task, and the multiplication when scaling, which turns three replicas into three charges. You know how to solve the first with spring.task.scheduling.pool.size or your own TaskScheduler with its ErrorHandler, and the second with ShedLock over the PostgreSQL we already had, understanding lockAtMostFor, lockAtLeastFor and why usingDbTime() removes the skew between clocks.
On the asynchronous side, you know that @Async suffers exactly the same proxy traps as @Transactional — and that self-invocation simply makes the asynchrony vanish without a single warning — and why the pool-less SimpleAsyncTaskExecutor is dangerous. You configure ThreadPoolTaskExecutor knowing that the queue fills up before the pool grows, you choose a rejection policy with judgement and you use separate executors per kind of work as a first bulkhead. You are clear on the chapter's most valuable rule: the transaction does not travel to the other thread, so you pass identifiers rather than entities, and the asynchronous work is fired at AFTER_COMMIT — the combination that gets the Ribalta citizen their 201 in forty milliseconds while the e-mail goes out on another thread. You know how to propagate the SecurityContext with DelegatingSecurityContextAsyncTaskExecutor and the MDC with a TaskDecorator, how to capture failures from void methods with AsyncUncaughtExceptionHandler, how to weigh up Java 21's virtual threads with their three warnings, and how to shut the executors down gracefully. And you know where the boundary lies: @Async gives neither durability nor retries, and when losing work is not acceptable the answer is a message queue, which will appear in 07-05 and 07-06.
With all of this, CicloUrbana is observable, knows how to adapt to its environment and works on its own. And it is still a JAR that somebody has to start by hand on a machine with Java 21 installed, the right time zone and the environment variables correctly set. That "somebody" and those conditions are the last hand-crafted link in the whole project: the reason it works on one server and not on another, and the reason deployment depends on a list of steps in one person's head. The next lesson, Spring Boot with Docker, removes it by packaging the application, its JRE and its configuration into a reproducible image: cache-friendly layers, an unprivileged user, a complete docker-compose.yml with PostgreSQL and its healthcheck wired to the probes from 07-01, and the door open to Kubernetes.
Spring Boot Course
Module 1: Introduction to Spring Boot
- What Is Spring Boot?
- Setting Up Your Development Environment
- Building Your First Spring Boot Application
- Understanding the Project Structure
- Application Startup and Lifecycle
Module 2: Spring Boot Core Concepts
- Spring Boot Annotations
- Dependency Injection in Spring Boot
- Bean Scope and Lifecycle
- Spring Boot Configuration
- Spring Boot Properties
- Auto-Configuration and Starters from the Inside
Module 3: Building RESTful Web Services
- Introduction to RESTful Web Services
- Creating REST Controllers
- Handling HTTP Methods
- Validating Input Data
- DTOs and Mapping Between Layers
- Exception Handling in REST
- Documenting the API with OpenAPI
Module 4: Data Access with Spring Boot
- Introduction to Spring Data JPA
- Configuring Data Sources
- Creating JPA Entities
- Relationships Between Entities
- Using Spring Data Repositories
- Query Methods in Spring Data JPA
- Transactions and Persistence Management
- Schema Migrations with Flyway
Module 5: Security in Spring Boot
- Introduction to Spring Security
- Configuring Spring Security
- User Authentication and Authorization
- Implementing JWT Authentication
- Method-Level Security and API Hardening
Module 6: Testing in Spring Boot
- Introduction to Testing
- Unit Testing with JUnit
- Mocking with Mockito
- Integration Testing
- Testing with Testcontainers
Module 7: Advanced Spring Boot Features
- Spring Boot Actuator
- Spring Boot Profiles
- Scheduled Tasks and Asynchronous Execution
- Spring Boot with Docker
- Spring Boot and Microservices
- Service Communication and Fault Tolerance
Module 8: Deploying Spring Boot Applications
- Introduction to Deployment
- Deploying to Heroku
- Deploying to AWS
- Deploying to Kubernetes
- Continuous Integration and Delivery
Module 9: Performance and Monitoring
- Performance Tuning
- Caching with Spring Cache
- Monitoring with Spring Boot Actuator
- Using Prometheus and Grafana
- Logging and Log Management
- Distributed Tracing
