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

Reactive job flow

The Request Path Is Only Half the Story

The exchange example already has reactive request paths:

That is useful, but it is not the whole system.

A crypto exchange-like application also has repeated background flows:

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:

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:

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:

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:

For the example branch, the simplest useful behavior is:

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:

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:

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:

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?

Continue Reading