The Tale of Async, Timeouts, and Why I Almost Gave Up on Java 🤦‍♂️

Introduction: The Simple Dream of Async 🛌💭

Ah, the sweet promise of @Async. Just slap this annotation on your method, and boom! Now you’ve got tasks running in the background. The main thread is free, happily managing other tasks while the background work hums along smoothly. Sounds like a dream, right?

Well, at first, it was a dream. I used @Async, and voila — my method ran asynchronously without a hitch! Hallelujah! 🙌

But then I thought, "What if I need a timeout? You know, so my tasks don’t run forever." Because who wants an async method to go on endlessly, like a marathoner with no finish line? So, I set up a timeout — thinking it would be just as easy as @Async. And that’s when the nightmare began… 😅


Part 1: Async Alone — When Everything was Perfect (For a While) 😇

Here’s where everything went great. I added the @Async annotation, and my long-running task ran on a separate thread — as promised. The ThreadPoolTaskExecutor was managing threads like a boss, and everything was golden.

Here’s the basic code I started with:

@Async("asyncExecutor")
public CompletableFuture<Void> processDataAsync(MyDataDTO dataDTO) {
    // Simulate some long-running task here
    dataProcessor.processData(dataDTO);
    return CompletableFuture.completedFuture(null);
}

And guess what? It worked! 🎉 The task ran on a separate thread, the main thread wasn’t held up, and everything was looking smooth. But... something was missing.


Part 2: Adding a Timeout (And Breaking Everything) ⏳💥

Now, I thought, “Wouldn’t it be awesome if I could cancel the task after a certain time?” Just in case it decided to hang around like that one friend who never knows when to leave. 🚪

So, I tried adding a timeout feature to stop the task if it took more than 20 seconds. Simple, right? Just introduce a timeout, and we’re good to go!

Or so I thought.

And Then, the Errors Began… 😱

The moment I combined @Async with timeouts, the errors started flooding in. First, I got the dreaded “Task rejected” error. My poor async task was being rejected like it wasn’t on the guest list. 🎟️

And here’s what I realized: each time the timeout kicked in, the thread pool was shutting down after handling just one request. And on the next request? Boom. The thread pool was dead. No new tasks could be scheduled.


Part 3: A Humble Solution — Getting Async and Timeouts to Play Nice 🎉

After hours of experimenting (and plenty of coffee ☕), I finally cracked it. Here’s what I learned along the way:

  1. Keep the executor running: Don’t shut down the executor after every request. That’s like trying to restart the whole kitchen every time you want to cook something. Leave it running!

  2. Use CompletableFuture for flexibility: CompletableFuture gives you more control and handles async tasks with timeout management in a cleaner way.

  3. Timeouts require their own thread: To apply a timeout, you need a separate scheduler. Using a ScheduledExecutorService worked like a charm for monitoring the timeout without interrupting the main task.

Here’s the final working code:

@Service
public class AsyncTaskService {
    private static final Logger logger = LoggerFactory.getLogger(AsyncTaskService.class);

    @Autowired
    private DataProcessor dataProcessor;

    @Autowired
    @Qualifier("asyncExecutor")
    private ThreadPoolTaskExecutor asyncExecutor;

    public CompletableFuture<Void> processDataAsync(MyDataDTO dataDTO) {
        CompletableFuture<Void> completableFuture = new CompletableFuture<>();

        // Get the underlying ExecutorService
        ExecutorService executorService = asyncExecutor.getThreadPoolExecutor();

        // Submit the task
        Future<?> future = executorService.submit(() -> {
            try {
                dataProcessor.processData(dataDTO);
                completableFuture.complete(null);
            } catch (Exception ex) {
                logger.error("Exception in processDataAsync:", ex);
                completableFuture.completeExceptionally(ex);
            }
        });

        // Add a timeout using a ScheduledExecutorService
        Executors.newSingleThreadScheduledExecutor().schedule(() -> {
            if (!future.isDone()) {
                logger.error("Task exceeded the time limit of 20 seconds!");
                future.cancel(true); // Cancel the task
                completableFuture.completeExceptionally(new TimeoutException("Task timed out after 20 seconds"));
            }
        }, 20, TimeUnit.SECONDS);

        return completableFuture;
    }
}

Part 4: Lessons Learned (And What NOT to Do) 📚

  1. Keep the executor running: Shutting down the thread pool after each request is like kicking out the chef after every order. Keep it alive so it can serve more requests!

  2. Use CompletableFuture over Future<?>: CompletableFuture is just cooler. It gives you more control and fits better in an async setup.

  3. Timeouts need their own thread: Don’t mix your timeout logic with the async task itself. Use a separate scheduler to monitor the timeout.


Conclusion: The Async Victory 🏆

After a long journey, the @Async and timeout feature finally worked as intended. No more unexpected shutdowns, no more task rejections. Just smooth async processing with a timeout that worked exactly as I wanted.

So if you’re out there trying to combine @Async and timeouts, don’t let Java reject you at the door. Keep your executor running, let CompletableFuture handle the async, and may your tasks always finish within their allotted time. 🕒

0
Subscribe to my newsletter

Read articles from Yash Raj Srivastav directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Yash Raj Srivastav
Yash Raj Srivastav

Software Developer + DevOps | Tech Blogger | DSA Practitioner | Java | SpringBoot(security, batch, cloud) | TypeScript | Python | Bash Script | Kafka | Redis | Payment Gateways | AWS | Azure | Docker | Kubernetes | Ansible | Jenkins | GitHub Actions