Efficient Task Management: Building a Java-Based Task Executor Service for Your Admin Panel
Implementation of a simple yet effective Java-based task executor service, analysis advantages, and finding simples solution.
Join the DZone community and get the full member experience.
Join For FreeIn today's data-driven world, effectively managing and processing large volumes of data is crucial for organizations to maintain a competitive edge. As systems become more complex and interconnected, developers must grapple with an array of challenges, such as data migrations, data backfilling, generating analytical reports, etc.
In this article, we will explore a simple yet effective Java-based implementation of a task executor service. We will discuss the key components of the service, examine some advantages of this approach, and provide insights to help developers make informed decisions when implementing a task executor service for their own projects.
System Overview
Service consists of a cluster of N servers responsible for processing user requests (API Servers), along with an administrator panel that allows monitoring and managing data within the system (Admin Panel).
Key features:
- API Servers and Admin Panel use a shared database to ensure efficient data access and storage.
- Access to the Admin Panel is restricted and available only to authorized personnel.
- All actions performed within the Admin Panel are logged in persistent storage for the following audit
Use Case: Display Available Tasks and Execute Them From the UI
In this use case, the Admin Panel's UI displays a list of available tasks and their status (either executing or idle). The task names are human-readable and uniquely identify the specific process they correspond to. Operators can trigger a task by its name, and the task's status will update in the UI.
Regarding the use case, we can determine the following functional requirements for server-side implementation:
- Obtaining a list of task names and statuses.
- Trigger task execution by its name
- The status of the task is set to “EXECUTING” when execution starts and to “IDLE” after it’s done.
- Parallel execution of the same task is not allowed due to it being idle or running at the same time.
Technical Implementation
Task Interface
To begin the implementation process, we define the task interface. Each task should have a human-readable name and a method to initiate its execution. This interface sets the foundation for custom tasks implemented to specific use cases.
public interface Task {
/**
* Returns the human-readable name of the task. Must be unique across existing tasks
*
* @return the name of the task
*/
String getName();
/**
* Starts the execution of the task.
*/
void execute();
}
By implementing this interface, developers can create custom execution logic with distinctive and descriptive names, ensuring that the task is easily identifiable within the system.
Implementation Covers Functional Requirements
Considering that the Admin Panel is a single-instance service and all the tasks are executed on it, we can store task statuses in memory, allowing for straightforward tracking of task invocations and start/finish events. This design choice simplifies the task management process and reduces the overhead associated with querying external data sources to fetch task statuses.
Therefore, the next step can be covering the functional requirements:
public class TaskService {
private final Map<String, Task> tasks;
private final Map<String, Boolean> running = new HashMap<>();
public TaskService(List<Task> tasks) {
this.tasks = tasks.stream().collect(Collectors.toMap(Task::getName, Function.identity()));
this.tasks.keySet().forEach(taskName -> running.put(taskName, false));
}
public Map<String, Boolean> getTasksStatuses() {
return Collections.unmodifiableMap(running);
}
public void start(String name) {
Task task = tasks.get(name);
if (task == null) {
throw new IllegalArgumentException("Unknown task '" + name + '\'');
}
if (running.get(name)) {
throw new IllegalStateException("Task is executing");
}
running.put(name, true);
task.execute();
running.put(name, false);
}
}
Method getTaskStatuses
provides good enough encapsulation: it returns an unmodifiable wrapper of the internal state: if the value is true — the task is executing; otherwise — idle. The method is refined. There is nothing to improve here.
We've implicitly validated that there are no duplicate task names. In the constructor, we stream the tasks and collect them into the map with a default merging strategy — throwing an exception on a duplicate key.
Method start(String)
checks if the task exists, then changes the task status, executes the task, and sets the status back. At first glance, it seems that it works fine, but this method has some issues.
- Execution may throw an exception, so the state of the task will remain unchanged after failed execution.
- Concurrency issue with test-and-set.
- The task executes in the same thread, which means that, in our case, we use the HTTP request thread. We should respond right after triggering the invocation, so the client application can easily determine that the server received the request and started working on it.
- Auditing logs are required — after some time, you may need to connect the dots and check when a task was executed. At that moment, server logs could be unreachable. Therefore, you need to write events of the invocation to a database.
Improving the Implementation
Keep in mind that the invocation may be interrupted, so we must always maintain the state. This particular case is quite obvious: we can easily wrap the invocation into a try block and change the status back in the final block.
try {
...
} finally {
running.put(name, false);
}
To fix the concurrency issue, we need to understand how happens before semantics works. running.get
and running.put
together are not atomic operations and therefore are not thread-safe. If there are two concurrent threads, both can pass testing the running.get(name)
before running.put
and nothing will prevent both of them from triggering the invocation.
There are several ways to solve this issue. It could be a synchronized block, a lock, atomics (make it Map<String, AtomicBoolean> running
). I'll use ConcurrentHashMap
instead.
Interface Map provides operations compute
, computeIfAbsent
and computeIfPresent
– they’re atomic in ConcurrentHashMap
, which means that whatever lambda you provide will be thread safe because it will be executed sequentially.
running.compute(name, (key, isRunning) -> {
if (Boolean.TRUE.equals(isRunning)) {
throw new TaskLockUnavailable(name + " is already running");
}
return true;
});
// here is safe to invoke the task
To release the triggering thread, we need to pass execution to java.util.concurrent.ExecutorService
. Validation should be synchronous. It creates a positive user experience when the user sees validation errors without delay. So, we need to check if the task is not found by the name and or is already executing before submitting the task to the executor.
Summarizing all the thoughts into a code listing, below is the finalized start method:
public void start(String name) {
Task task = tasks.get(name);
if (task == null) {
throw new IllegalArgumentException("Unknown task '" + name + '\'');
}
running.compute(name, (key, isRunning) -> {
if (Boolean.TRUE.equals(isRunning)) {
throw new TaskLockUnavailable(name + " is already running");
}
return true;
});
executorService.submit(() -> {
try {
doExecuteTask(task);
} finally {
running.put(name, false);
}
});
}
As you can see, I’ve defined doExecuteTask
as a separate method. I did it to enhance the invocation by saving events for the following audit. It may look like this:
private void doExecuteTask(Task task) {
String name = task.getName();
try {
taskExecutionAuditor.started(name);
task.execute();
taskExecutionAuditor.done(name);
} catch (Exception e) {
taskExecutionAuditor.error(name, e);
}
}
Implementation of the TaskExecutionAuditor
I’ll leave it out of the scope of this article, as it usually heavily depends on specific requirements, which could vary greatly between different use cases and organizations.
Conclusion
In this article, we dove into the problem of invoking long-running tasks in a service with multiple API Servers and an Admin Panel. We examined a simple yet effective Java-based task executor service tailored for an Admin Panel, which despite its simplicity, efficiently manages and processes large volumes of data.
While we covered the core aspects of the task-executing service, there are several topics beyond the scope of this article. These topics include interrupting task invocations, restoring and continuing running tasks after the server restarts, scaling the invocation, and more. These advanced features may be worthwhile to consider when designing a robust and scalable task management solution.
Nevertheless, the presented implementation serves as a solid foundation for a sort of MVP of your own task invocation framework. It provides a starting point from which you can further develop and customize the solution to meet your specific functional and non-functional requirements. By developing upon this foundation, you can create a powerful task management system that caters to the unique needs of your application and infrastructure. This will ensure efficient and reliable processing of long-running tasks.
Opinions expressed by DZone contributors are their own.
Comments