Reactive Background Jobs, Alerts, and Tests Without Deadlocks
It is possible to migrate request handlers to reactive code and still leave one of the most repetitive parts of the system blocking.
That usually happens in background work.
This series is based on hands-on migration work where the request path was not the only problem. Alert processing, reminder flows, scheduled scans, and tests were just as important because they were repeated constantly and were easy to forget during a controller-focused migration.
The previous article moved the conversation from “migrate easy CRUD first” to “migrate hot paths first.” Background jobs are one of the clearest examples of that rule.
Repo branch for this article: article-7-reactive-jobs-and-tests
The Request Path Is Only Half the Story
The exchange example already has reactive request paths:
- market search uses a stateless reactive projection
- recent trades use a stateless reactive projection
- price alert creation uses a managed reactive write
That is useful, but it is not the whole system.
A crypto exchange-like application also has repeated background flows:
- scan enabled price alerts
- compare alert targets with the latest market price
- build delivery candidates
- send notifications
- disable or update alerts after successful delivery
- record or report failures
Those flows are not always visible in the UI, but they can dominate operational behavior because they run again and again.
If they remain blocking, the migration is incomplete in one of the places where reactive execution can matter most.
The Old Shape Looks Harmless
A blocking job often starts like this:
public void processPriceAlerts() {
List<PriceAlert> alerts = alertRepository.findEnabled();
for (PriceAlert alert : alerts) {
if (shouldFire(alert)) {
sender.send(alert);
alert.enabled = false;
}
}
}
This is simple, but the simplicity hides the wrong things:
- candidate lookup is blocking
- delivery is mixed with persistence
- failures usually break the loop or disappear into logging
- tests often use waits that can hang forever
- the job has no clean reactive composition point
That style is tolerable for a tiny workload. It becomes painful when the candidate set grows or when CI starts hanging because one branch of a reactive test never completes.
Candidate Lookup Is the Real Hot Query
For price alerts, the most interesting query is not “load one alert by id.”
It is “find the alerts that should fire now.”
That is a projection-oriented query:
@WithTransaction(stateless = true)
public Uni<List<PriceAlertCandidate>> findTriggeredCandidates() {
return PriceAlert_.statelessReactive()
.find(
"""
select a.id, u.email, m.symbol, a.targetPrice, m.lastTradePrice, a.direction
from PriceAlert a
join a.userAccount u
join a.market m
where a.enabled = true
and (
(a.direction = 'ABOVE' and m.lastTradePrice >= a.targetPrice)
or
(a.direction = 'BELOW' and m.lastTradePrice <= a.targetPrice)
)
order by a.id
"""
)
.project(PriceAlertCandidate.class)
.list();
}
This is a good statelessReactive() candidate because it:
- reads repeatedly
- returns a delivery-specific shape
- does not need managed entity state
- avoids loading full alert, user, and market entities
- has clear operational value
This is the same reasoning used for market search and the trade feed, but applied outside the request path.
The Scheduler Should Be the Boundary
The scheduler should usually be the outer subscription boundary.
The inner service should compose Uni operations. In an application with an actual scheduled runner, the scheduler should trigger the flow and subscribe once:
@Scheduled(every = "1m")
void processPriceAlerts() {
priceAlertJobService.processTriggeredAlerts()
.subscribe().with(
ignored -> {
},
failure -> log.error("Price alert job failed", failure)
);
}
The rule is simple:
The boundary subscribes. The inner services compose.
That keeps blocking waits out of the service layer and makes the job easier to test directly.
Delivery Should Stay Composable
Once candidate lookup is reactive, the delivery step should not drag the flow back to blocking style.
A useful interface is:
public interface PriceAlertDelivery {
Uni<Void> send(PriceAlertCandidate candidate);
}
The job service can then compose lookup, delivery, and post-delivery updates:
public Uni<Integer> processTriggeredAlerts() {
return findTriggeredCandidates()
.onItem().transformToUni(candidates ->
Multi.createFrom().iterable(candidates)
.onItem().transformToUniAndConcatenate(this::sendAndDisable)
.collect().asList()
)
.map(List::size);
}
This example uses sequential composition deliberately. A real application could introduce bounded concurrency later, but I would not start there. The first goal is a correct flow with explicit boundaries and deterministic tests.
Updating State Still Needs Managed Semantics
Candidate lookup is a stateless projection.
Disabling the alert after delivery is not.
That update should use a managed reactive operation because it changes entity state:
private Uni<Void> sendAndDisable(PriceAlertCandidate candidate) {
return delivery.send(candidate)
.chain(() -> PriceAlert_.managedReactive().findById(candidate.alertId()))
.invoke(alert -> alert.enabled = false)
.replaceWithVoid();
}
This is the same managed/stateless distinction from the previous article:
- stateless reactive for projection-heavy candidate lookup
- managed reactive for entity lifecycle changes
Trying to force both into one helper usually makes the code less honest.
Failure Handling Must Be Visible
A background job should not swallow failures silently.
At minimum, the design should make these decisions explicit:
- does one failed delivery stop the whole job?
- does it skip that candidate and continue?
- is failure recorded somewhere?
- will the candidate be retried later?
- should the alert remain enabled after failure?
For the example branch, the simplest useful behavior is:
- send matching alerts
- disable only successfully sent alerts
- fail the returned
Uniif delivery fails - let the scheduler log the failure
- keep tests bounded so failures do not become CI hangs
That is enough to demonstrate the migration pattern without pretending to build a full notification platform.
The Test Problem
Reactive job tests often start with this:
service.processTriggeredAlerts().await().indefinitely();
That is a bad default.
If one mock returns null, one branch never completes, or one chain is incomplete, the test does not fail in a useful way. It waits until the external build timeout kills the job.
In CI/CD, that is worse than a normal failure.
Use Bounded Waits
The safer pattern is a shared timeout:
private static final Duration WAIT_TIMEOUT = Duration.ofSeconds(10);
and then:
Integer processed = jobService.processTriggeredAlerts()
.await().atMost(WAIT_TIMEOUT);
This turns non-termination into a normal test failure with a useful location.
For background flows, I would rather see a deterministic timeout than an indefinitely stuck pipeline.
What To Test
For this branch, the useful tests are small but targeted:
- candidate lookup finds triggered alerts
- the job sends triggered alerts
- successfully sent alerts are disabled
- non-triggered alerts remain enabled
- a delivery failure terminates predictably
- no test waits indefinitely
Those tests are more valuable than only testing the scheduler annotation. The scheduler is mostly a boundary. The important behavior is in the job service.
The compiled branch tests the job service directly with Quarkus Vert.x test support. That keeps the test on the right context for Hibernate Reactive and still avoids unbounded waits by using a shared timeout around reactive setup operations.
Why This Matters
Teams sometimes treat scheduler migration as cleanup after the request path is done.
That misses where a lot of repeated work happens.
Background jobs often:
- scan many rows
- fan out into delivery calls
- repeat continuously
- expose hidden blocking waits
- create the worst CI failures when tests hang
That makes them high-value migration targets, not afterthoughts.
Closing
A system is not meaningfully reactive just because several resources return Uni.
If alert processing, digest delivery, reminder flows, and candidate scans still run as blocking loops, the migration has not reached one of the most important parts of the application.
The pattern I prefer is:
- stateless reactive projection for candidate lookup
- reactive delivery contract
- managed reactive update for state changes
- scheduler as the subscription boundary
- bounded waits in tests
The final article in this series will step back from the details and answer a broader question: if I were planning this migration again from day one, what would I do differently?